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", "type": "node",
"request": "launch", "request": "launch",
"cwd": "${workspaceRoot}", "cwd": "${workspaceRoot}",
@@ -35,8 +35,8 @@
], ],
"compounds": [ "compounds": [
{ {
"name": "Extension + Server", "name": "Extension + Debugger",
"configurations": [ "Launch Extension", "Server" ] "configurations": [ "Launch Extension", "Debugger Server" ]
} }
] ]
} }

View File

@@ -3,6 +3,7 @@
const vscode = require('vscode'); const vscode = require('vscode');
const { AndroidContentProvider } = require('./src/contentprovider'); const { AndroidContentProvider } = require('./src/contentprovider');
const { openLogcatWindow } = require('./src/logcat'); const { openLogcatWindow } = require('./src/logcat');
const { selectAndroidProcessID } = require('./src/process-attach');
// this method is called when your extension is activated // this method is called when your extension is activated
// your extension is activated the very first time the command is executed // 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', () => { vscode.commands.registerCommand('android-dev-ext.view_logcat', () => {
openLogcatWindow(vscode); 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); context.subscriptions.splice(context.subscriptions.length, 0, ...disposables);

View File

@@ -18,7 +18,8 @@
"theme": "dark" "theme": "dark"
}, },
"activationEvents": [ "activationEvents": [
"onCommand:android-dev-ext.view_logcat" "onCommand:android-dev-ext.view_logcat",
"onCommand:PickAndroidProcess"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
@@ -128,30 +129,84 @@
"default": false "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": [ "initialConfigurations": [
{ {
"type": "android", "type": "android",
"name": "Android",
"request": "launch", "request": "launch",
"name": "Android launch",
"appSrcRoot": "${workspaceRoot}/app/src/main", "appSrcRoot": "${workspaceRoot}/app/src/main",
"apkFile": "${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk", "apkFile": "${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk",
"adbPort": 5037 "adbPort": 5037
},
{
"type": "android",
"request": "attach",
"name": "Android attach",
"appSrcRoot": "${workspaceRoot}/app/src/main",
"adbPort": 5037,
"processId": "${command:PickAndroidProcess}"
} }
], ],
"configurationSnippets": [ "configurationSnippets": [
{ {
"label": "Android: Launch Configuration", "label": "Android: Launch Application",
"description": "A new configuration for launching an Android app debugging session", "description": "A new configuration for launching an Android app debugging session",
"body": { "body": {
"type": "android", "type": "android",
"request": "launch", "request": "launch",
"name": "${2:Launch App}", "name": "${2:Android Launch}",
"appSrcRoot": "^\"\\${workspaceRoot}/app/src/main\"", "appSrcRoot": "^\"\\${workspaceRoot}/app/src/main\"",
"apkFile": "^\"\\${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk\"", "apkFile": "^\"\\${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk\"",
"adbPort": 5037 "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": {} "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.connect_to_adb();
await this.adbsocket.cmd_and_status(`host:transport:${this.deviceid}`); 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(); 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 * 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.connect_to_adb();
await this.adbsocket.cmd_and_status(`host:transport:${this.deviceid}`); 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(); await this.disconnect_from_adb();
return stdout; return stdout;
} }
@@ -145,7 +196,7 @@ class ADBClient {
/** /**
* Starts the Logcat monitor. * Starts the Logcat monitor.
* Logcat lines are passed back via onlog callback. If the device disconnects, onclose is called. * 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) { async startLogcatMonitor(o) {
// onlog:function(e) // onlog:function(e)
@@ -157,37 +208,41 @@ class ADBClient {
if (!o.onlog) { if (!o.onlog) {
const logcatbuffer = await this.adbsocket.read_stdout(); const logcatbuffer = await this.adbsocket.read_stdout();
await this.disconnect_from_adb(); await this.disconnect_from_adb();
return logcatbuffer; return logcatbuffer.toString();
} }
// start the logcat monitor // start the logcat monitor
let logcatbuffer = Buffer.alloc(0);
const next_logcat_lines = async () => { const next_logcat_lines = async () => {
// read the next data from ADB let logcatbuffer = Buffer.alloc(0);
let next_data; let next_data;
try{ for (;;) {
next_data = await this.adbsocket.read_stdout(null); // read the next data from ADB
} catch(e) { try {
o.onclose(); next_data = await this.adbsocket.read_stdout();
return; } 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(); next_logcat_lines();
} }

View File

@@ -4,7 +4,6 @@ const {
Thread, StackFrame, Scope, Source, Breakpoint } = require('vscode-debugadapter'); Thread, StackFrame, Scope, Source, Breakpoint } = require('vscode-debugadapter');
// node and external modules // node and external modules
const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
@@ -17,7 +16,8 @@ const { evaluate } = require('./expression/evaluate');
const { PackageInfo } = require('./package-searcher'); const { PackageInfo } = require('./package-searcher');
const ADBSocket = require('./sockets/adbsocket'); const ADBSocket = require('./sockets/adbsocket');
const { AndroidThread } = require('./threads'); 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 { hasValidSourceFileExtension } = require('./utils/source-file');
const { VariableManager } = require('./variable-manager'); 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) // the base folder of the app (where AndroidManifest.xml and source files should be)
this.app_src_root = '<no appSrcRoot>'; 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 // packages we found in the source tree
this.src_packages = { this.src_packages = {
last_src_modified: 0, last_src_modified: 0,
@@ -50,8 +43,17 @@ class AndroidDebugSession extends DebugSession {
this._device = null; this._device = null;
// the API level of the device we are debugging // the API level of the device we are debugging
this.device_api_level = ''; this.device_api_level = '';
// the full file path name of the AndroidManifest.xml, taken from the manifestFile launch property // the full file path name of the AndroidManifest.xml, taken from the manifestFile launch property
this.manifest_fpn = ''; 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` * 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 // trace flag for printing diagnostic messages to the client Output Window
this.trace = false; 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 debugger uses one-based lines and columns
this.setDebuggerLinesStartAt1(true); 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) { if (args && args.trace) {
this.trace = args.trace; this.trace = args.trace;
onMessagePrint(this.LOG.bind(this)); 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 // 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.app_src_root = ensure_path_end_slash(args.appSrcRoot);
this.apk_fpn = args.apkFile; 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'); 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 // make sure ADB exists and is started and look for a device to install on
await this.checkADBStarted(args.autoStartADB !== false); await checkADBStarted(args.autoStartADB !== false);
this._device = await this.findSuitableDevice(args.targetDevice); this._device = await this.findSuitableDevice(args.targetDevice, true);
this._device.adbclient = new ADBClient(this._device.serial); this._device.adbclient = new ADBClient(this._device.serial);
// install the APK we are going to debug // install the APK we are going to debug
@@ -336,6 +481,8 @@ class AndroidDebugSession extends DebugSession {
// launch the app // launch the app
await this.startLaunchActivity(args.launchActivity); await this.startLaunchActivity(args.launchActivity);
this.debuggerAttached = true;
// if we get this far, the debugger is connected and waiting for the resume command // if we get this far, the debugger is connected and waiting for the resume command
// - set up some events... // - set up some events...
this.dbgr.on('bpstatechange', e => this.onBreakpointStateChange(e)) 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) { checkBuildIsUpToDate(staleBuild) {
// check if any source file was modified after the apk // check if any source file was modified after the apk
if (this.src_packages.last_src_modified >= this.apk_file_info.app_modified) { 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(); const apilevel = await this.getDeviceAPILevel();
// look for the android sources folder appropriate for this device // look for the android sources folder appropriate for this device
if (process.env.ANDROID_HOME && apilevel) { this._android_sources_path = getAndroidSourcesFolder(apilevel, true);
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;
});
}
} }
async getDeviceAPILevel() { async getDeviceAPILevel() {
@@ -491,11 +618,13 @@ class AndroidDebugSession extends DebugSession {
/** /**
* @param {string} target_deviceid * @param {string} target_deviceid
* @param {boolean} show_progress
*/ */
async findSuitableDevice(target_deviceid) { async findSuitableDevice(target_deviceid, show_progress) {
this.LOG('Searching for devices...'); show_progress && this.LOG('Searching for devices...');
const devices = await this.dbgr.listConnectedDevices() 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; let reject;
if (devices.length === 0) { if (devices.length === 0) {
reject = 'No devices are connected'; reject = 'No devices are connected';
@@ -543,11 +672,17 @@ class AndroidDebugSession extends DebugSession {
async disconnectRequest(response/*, args*/) { async disconnectRequest(response/*, args*/) {
D('disconnectRequest'); D('disconnectRequest');
this._isDisconnecting = true; this._isDisconnecting = true;
try { if (this.debuggerAttached) {
await this.dbgr.forceStop(); try {
await this.dbgr.disconnect(); if (this.debug_mode === 'launch') {
this.LOG(`Debugger stopped`); await this.dbgr.forceStop();
} catch (e) { this.LOG(`Debugger stopped`);
} else {
await this.dbgr.disconnect();
this.LOG(`Debugger detached`);
}
} catch (e) {
}
} }
this.sendResponse(response); this.sendResponse(response);
} }
@@ -635,10 +770,10 @@ class AndroidDebugSession extends DebugSession {
const bp_queue_len = this._set_breakpoints_queue.push({args,response,relative_fpn}); const bp_queue_len = this._set_breakpoints_queue.push({args,response,relative_fpn});
if (bp_queue_len === 1) { if (bp_queue_len === 1) {
do { do {
const next_bp = this._set_breakpoints_queue[0]; const { args, relative_fpn, response } = this._set_breakpoints_queue[0];
const javabp_arr = await this._setup_breakpoints(next_bp); const javabp_arr = await this.setupBreakpointsInFile(args.breakpoints, relative_fpn);
// send back the VS Breakpoint instances // 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 // .. and do the next one
this._set_breakpoints_queue.shift(); this._set_breakpoints_queue.shift();
} while (this._set_breakpoints_queue.length); } while (this._set_breakpoints_queue.length);
@@ -646,43 +781,42 @@ class AndroidDebugSession extends DebugSession {
} }
/** /**
* @param {*} o *
* @param {number} idx * @param {*[]} breakpoints
* @param {*[]} javabp_arr * @param {string} relative_fpn
*/ */
async _setup_breakpoints(o, idx = 0, javabp_arr = []) { async setupBreakpointsInFile(breakpoints, relative_fpn) {
const src_bp = o.args.breakpoints[idx]; const java_breakpoints = [];
if (!src_bp) { for (let idx = 0; idx < breakpoints.length; idx++) {
// end of list const src_bp = breakpoints[idx];
return javabp_arr; const dbgline = this.convertClientLineToDebugger(src_bp.line);
} const options = new BreakpointOptions();
const dbgline = this.convertClientLineToDebugger(src_bp.line); if (src_bp.hitCondition) {
const options = new BreakpointOptions(); // the hit condition is an expression that requires evaluation
if (src_bp.hitCondition) { // until we get more comprehensive evaluation support, just allow integer literals
// the hit condition is an expression that requires evaluation const m = src_bp.hitCondition.match(/^\s*(?:0x([0-9a-f]+)|0b([01]+)|0*(\d+([e]\+?\d+)?))\s*$/i);
// until we get more comprehensive evaluation support, just allow integer literals if (m) {
const m = src_bp.hitCondition.match(/^\s*(?:0x([0-9a-f]+)|0b([01]+)|0*(\d+([e]\+?\d+)?))\s*$/i); const hitcount = m[3] ? parseFloat(m[3]) : m[2] ? parseInt(m[2],2) : parseInt(m[1],16);
if (m) { if ((hitcount > 0) && (hitcount <= 0x7fffffff)) {
const hitcount = m[3] ? parseFloat(m[3]) : m[2] ? parseInt(m[2],2) : parseInt(m[1],16); options.hitcount = hitcount;
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); return java_breakpoints;
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);
}; };
async setExceptionBreakPointsRequest(response /*: SetExceptionBreakpointsResponse*/, args /*: SetExceptionBreakpointsArguments*/) { 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 // don't actually attempt to load the file here - just recheck to see if the sources
// path is valid yet. // path is valid yet.
if (process.env.ANDROID_HOME && this.device_api_level) { this._android_sources_path = getAndroidSourcesFolder(this.device_api_level, true);
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;
});
}
response.body = { content }; response.body = { content };
this.sendResponse(response); this.sendResponse(response);

View File

@@ -82,10 +82,10 @@ class DebugSession {
this.classPrepareFilters = new Set(); this.classPrepareFilters = new Set();
/** /**
* The set of class signatures already prepared * The set of class signatures loaded by the runtime
* @type {Set<string>} * @type {Set<string>}
*/ */
this.preparedClasses = new Set(); this.loadedClasses = new Set();
/** /**
* Enabled step JDWP IDs for each thread * Enabled step JDWP IDs for each thread

View File

@@ -75,11 +75,17 @@ class Debugger extends EventEmitter {
* @param {string} deviceid * @param {string} deviceid
*/ */
async startDebugSession(build, deviceid) { async startDebugSession(build, deviceid) {
if (this.status() !== 'disconnected') {
throw new Error('startDebugSession: session already active');
}
this.session = new DebugSession(build, deviceid); this.session = new DebugSession(build, deviceid);
const stdout = await Debugger.runApp(deviceid, build.startCommandArgs, build.postLaunchPause); const stdout = await Debugger.runApp(deviceid, build.startCommandArgs, build.postLaunchPause);
// retrieve the list of debuggable processes // 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 // choose the last pid in the list
const pid = pids[pids.length - 1]; const pid = pids[pids.length - 1];
// after connect(), the caller must call resume() to begin // after connect(), the caller must call resume() to begin
@@ -87,6 +93,20 @@ class Debugger extends EventEmitter {
return stdout; 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} deviceid Device ID to connect to
* @param {string[]} launch_cmd_args Array of arguments to pass to 'am start' * @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 * Retrieve a list of debuggable process IDs from a device
* @param {string} deviceid
* @param {number} timeout_ms
*/ */
getDebuggablePIDs(deviceid) { static getDebuggablePIDs(deviceid, timeout_ms) {
return new ADBClient(deviceid).jdwp_list(); return new ADBClient(deviceid).jdwp_list(timeout_ms);
} }
async getDebuggableProcesses(deviceid) { /**
const adbclient = new ADBClient(deviceid); * Retrieve a list of debuggable process IDs with process names from a device.
const info = { * For Android, the process name is usually the package name.
debugger: this, * @param {string} deviceid
jdwps: null, * @param {number} timeout_ms
}; */
const jdwps = await info.adbclient.jdwp_list(); static getDebuggableProcesses(deviceid, timeout_ms) {
if (!jdwps.length) return new ADBClient(deviceid).named_jdwp_list(timeout_ms);
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;
} }
/** /**
@@ -227,6 +214,7 @@ class Debugger extends EventEmitter {
async performConnectionTasks() { async performConnectionTasks() {
// setup port forwarding // setup port forwarding
// note that this call generally succeeds - even if the JDWP pid is invalid
await new ADBClient(this.session.deviceid).jdwp_forward({ await new ADBClient(this.session.deviceid).jdwp_forward({
localport: this.connection.localport, localport: this.connection.localport,
jdwp: this.connection.jdwp, jdwp: this.connection.jdwp,
@@ -236,13 +224,21 @@ class Debugger extends EventEmitter {
// after this, the client keeps an open connection until // after this, the client keeps an open connection until
// jdwp_disconnect() is called // jdwp_disconnect() is called
this.session.adbclient = new ADBClient(this.session.deviceid); this.session.adbclient = new ADBClient(this.session.deviceid);
await this.session.adbclient.jdwp_connect({ try {
localport: this.connection.localport, // if the JDWP pid is invalid (doesn't exist, not debuggable, etc) ,this
onreply: data => this._onJDWPMessage(data), // is where it will fail...
ondisconnect: () => this._onJDWPDisconnect(), 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 // handshake has completed
this.connection.connected = true; this.connection.connected = true;
// call suspend first - we shouldn't really need to do this (as the debugger // 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 // is already suspended and will not resume until we tell it), but if we
// don't do this, it logs a complaint... // 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 // set the class loader event notifier so we can enable breakpoints when the
// runtime loads the classes // runtime loads the classes
await this.initClassPrepareForBreakpoints(); 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 // reset the breakpoint states
this.resetBreakpoints(); this.resetBreakpoints();
this.emit('disconnect');
// clear the session
const adbclient = this.session.adbclient;
this.session = null;
// perform the JDWP disconnect // perform the JDWP disconnect
if (connection.connected) { if (connection.connected) {
await this.session.adbclient.jdwp_disconnect(); await adbclient.jdwp_disconnect();
} }
// undo the portforwarding // undo the portforwarding
// todo: replace remove_all with remove_port // todo: replace remove_all with remove_port
if (connection.portforwarding) { if (connection.portforwarding) {
await new ADBClient(this.session.deviceid).forward_remove_all(); await adbclient.forward_remove_all();
} }
// mark the port as freed // mark the port as freed
@@ -355,8 +360,7 @@ class Debugger extends EventEmitter {
Debugger.portManager.freeport(connection.localport); Debugger.portManager.freeport(connection.localport);
} }
// clear the session this.emit('disconnect');
this.session = null;
return previous_state; return previous_state;
} }
@@ -539,17 +543,12 @@ class Debugger extends EventEmitter {
*/ */
async initialiseBreakpoint(bp) { async initialiseBreakpoint(bp) {
// try and load the class - if the runtime hasn't loaded it yet, this will just return a TypeNotAvailable instance // 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); 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) { if (!bploc) {
// we couldn't identify a matching location - either the class is not yet loaded or the // 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 // 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 // if the class prepare events have overlapping packages (mypackage.*, mypackage.another.*), we will get
// multiple notifications (which duplicates breakpoints, etc) // multiple notifications (which duplicates breakpoints, etc)
const signature = prepared_class.type.signature; const signature = prepared_class.type.signature;
if (this.session.preparedClasses.has(signature)) { if (this.session.loadedClasses.has(signature)) {
return; // we already know about this return; // we already know about this
} }
this.session.preparedClasses.add(signature); this.session.loadedClasses.add(signature);
D('Prepared: ' + signature); D('Prepared: ' + signature);
if (!/^L(.*);$/.test(signature)) { if (!/^L(.*);$/.test(signature)) {
// unrecognised type signature - ignore it // unrecognised type signature - ignore it

View File

@@ -1534,7 +1534,7 @@ class JDWP {
const res = []; const res = [];
let arrlen = DataCoder.decodeInt(o); let arrlen = DataCoder.decodeInt(o);
while (--arrlen >= 0) { 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; return res;
} }

View File

@@ -7,6 +7,8 @@ const WebSocketServer = require('ws').Server;
// our stuff // our stuff
const { ADBClient } = require('./adbclient'); const { ADBClient } = require('./adbclient');
const { AndroidContentProvider } = require('./contentprovider'); const { AndroidContentProvider } = require('./contentprovider');
const { checkADBStarted } = require('./utils/android');
const { selectTargetDevice } = require('./utils/device');
const { D } = require('./utils/print'); const { D } = require('./utils/print');
/** /**
@@ -292,83 +294,58 @@ function onWebSocketClientConnection(client, req) {
client._socket && typeof(client._socket.setNoDelay)==='function' && client._socket.setNoDelay(true); client._socket && typeof(client._socket.setNoDelay)==='function' && client._socket.setNoDelay(true);
} }
function getADBPort() { /**
const defaultPort = 5037; * @param {import('vscode')} vscode
const adbPort = AndroidContentProvider.getLaunchConfigSetting('adbPort', defaultPort); * @param {*} target_device
if (typeof adbPort === 'number' && adbPort === (adbPort|0)) */
return adbPort; function openWebviewLogcatWindow(vscode, target_device) {
return defaultPort; 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() * @param {import('vscode')} vscode
.then(err => { * @param {*} target_device
// if adb is not running, see if we can start it ourselves using ANDROID_HOME (and a sensible port number) */
const adbport = getADBPort(); 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); const autoStartADB = AndroidContentProvider.getLaunchConfigSetting('autoStartADB', true);
if (err && autoStartADB!==false && process.env.ANDROID_HOME && typeof adbport === 'number' && adbport > 0 && adbport < 65536) { await checkADBStarted(autoStartADB);
const adbpath = path.join(process.env.ANDROID_HOME, 'platform-tools', /^win/.test(process.platform)?'adb.exe':'adb');
const adbargs = ['-P',''+adbport,'start-server']; let target_device = await selectTargetDevice(vscode, "Logcat display");
try { if (!target_device) {
/*const stdout = */require('child_process').execFileSync(adbpath, adbargs, {cwd:process.env.ANDROID_HOME, encoding:'utf8'}); return;
} 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()) if (vscode.window.createWebviewPanel) {
.then(devices => { // newer versions of vscode use WebviewPanels
switch(devices.length) { openWebviewLogcatWindow(vscode, target_device);
case 0: } else {
vscode.window.showInformationMessage('Logcat cannot be displayed. No Android devices are currently connected'); // older versions of vscode use previewHtml
return null; openPreviewHtmlLogcatWindow(vscode, target_device);
case 1:
return devices; // only one device - just show it
} }
const prefix = 'Android: View Logcat - ', all = '[ Display All ]'; } catch (e) {
const devicelist = devices.map(d => prefix + d.serial); vscode.window.showInformationMessage(`Logcat cannot be displayed. ${e.message}`);
//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?');
});
} }
module.exports = { 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 * Sends an ADB command, checks the returned status and then reads raw data from the socket
* @param {string} command * @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); 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 {number|'length+data'|undefined} length
* @param {string} [format] * @param {string} [format]
* @param {number} [timeout_ms]
*/ */
async read_bytes(length, format) { async read_bytes(length, format, timeout_ms) {
//D(`reading ${length} bytes`); //D(`reading ${length} bytes`);
let actual_length = length; let actual_length = length;
if (typeof actual_length === 'undefined') { if (typeof actual_length === 'undefined') {
@@ -95,25 +96,40 @@ class AndroidSocket extends EventEmitter {
return Promise.resolve(data); return Promise.resolve(data);
} }
// wait for the socket to update and then retry the read // 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); 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) => { return new Promise((resolve, reject) => {
let done = 0; let done = 0, timer = null;
let onDataChanged = () => { let onDataChanged = () => {
if ((done += 1) !== 1) return; if ((done += 1) !== 1) return;
this.off('socket-ended', onSocketEnded); this.off('socket-ended', onSocketEnded);
clearTimeout(timer);
resolve(); resolve();
} }
let onSocketEnded = () => { let onSocketEnded = () => {
if ((done += 1) !== 1) return; if ((done += 1) !== 1) return;
this.off('data-changed', onDataChanged); this.off('data-changed', onDataChanged);
clearTimeout(timer);
reject(new Error(`${this.which} socket read failed. Socket closed.`)); 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('data-changed', onDataChanged);
this.once('socket-ended', onSocketEnded); 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); 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) => { return new Promise((resolve, reject) => {
this.check_socket_active('write'); this.check_socket_active('write');
try { try {
// @ts-ignore
const flushed = this.socket.write(bytes, () => { const flushed = this.socket.write(bytes, () => {
flushed ? resolve() : this.socket.once('drain', resolve); 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); 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 * Adds a callback to be called when any message is output
* @param {Function} cb * @param {Function} cb
@@ -45,7 +62,9 @@ function onMessagePrint(cb) {
module.exports = { module.exports = {
D, D,
E, E,
initLogToClient,
LOG,
W, W,
onMessagePrint, onMessagePrint,
} }