mirror of
https://github.com/adelphes/android-dev-ext.git
synced 2025-12-22 09:29:38 +00:00
* upgrade package-lock.jsons * upgrade debugadapter package * upgrade debugprotocol package * upgrade long package * upgrade unzipper package * upgrade uuid package * upgrade ws package * upgrade xpath package * update dev dependencies * fix eslint config to allow newer language features (async shorthand functions and optional catch parameters) * fix import type declarations * update eslint * remove unsupported stopOnEntry properties * code tidy - fix warnings, separate type imports from value imports, remove unused code * report stack on adb connection error and default host name to 127.0.0.1 * fix imported types in jdwp * lang server tidyups * add a new helper for creating android API library cache file * update the android API cache file to 34 * bump to version 1.4.0
1548 lines
65 KiB
JavaScript
1548 lines
65 KiB
JavaScript
const {
|
|
DebugSession,
|
|
InitializedEvent, TerminatedEvent, StoppedEvent, BreakpointEvent, ThreadEvent, OutputEvent,
|
|
Thread, StackFrame, Scope, Source, Breakpoint } = require('@vscode/debugadapter');
|
|
|
|
// node and external modules
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
// our stuff
|
|
const { ADBClient } = require('./adbclient');
|
|
const { APKFileInfo } = require('./apk-file-info');
|
|
const { Debugger } = require('./debugger');
|
|
const { AttachBuildInfo, BreakpointOptions, DebuggerException, JavaClassType, LaunchBuildInfo } = require('./debugger-types');
|
|
const { evaluate } = require('./expression/evaluate');
|
|
const { PackageInfo } = require('./package-searcher');
|
|
const ADBSocket = require('./sockets/adbsocket');
|
|
const { AndroidThread } = require('./threads');
|
|
const { checkADBStarted, getAndroidSourcesFolder } = require('./utils/android');
|
|
const { D, initLogToClient, onMessagePrint } = require('./utils/print');
|
|
const { hasValidSourceFileExtension } = require('./utils/source-file');
|
|
const analytics = require('../langserver/analytics');
|
|
|
|
/**
|
|
* @typedef {import('./debugger-types').DebuggerValue} DebuggerValue
|
|
* @typedef {import('./debugger-types').JavaBreakpointEvent} JavaBreakpointEvent
|
|
* @typedef {import('./debugger-types').JavaExceptionEvent} JavaExceptionEvent
|
|
* @typedef {import('./debugger-types').SourceLocation} SourceLocation
|
|
* @typedef {import('./variable-manager').VariableManager} VariableManager
|
|
*/
|
|
|
|
class AndroidDebugSession extends DebugSession {
|
|
|
|
/**
|
|
* Creates a new debug adapter that is used for one debug session.
|
|
*/
|
|
constructor() {
|
|
super();
|
|
// create the Android debugger instance - we proxy requests through this
|
|
this.dbgr = new Debugger();
|
|
|
|
// the base folder of the app (where AndroidManifest.xml and source files should be)
|
|
this.app_src_root = '<no appSrcRoot>';
|
|
// packages we found in the source tree
|
|
this.src_packages = {
|
|
last_src_modified: 0,
|
|
/** @type {Map<string,PackageInfo>} */
|
|
packages: new Map(),
|
|
};
|
|
// the device we are debugging
|
|
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`
|
|
* @type {string[]}
|
|
*/
|
|
this.pm_install_args = null;
|
|
|
|
/**
|
|
* array of custom arguments to pass to `am start`
|
|
* @type {string[]}
|
|
*/
|
|
this.am_start_args = null;
|
|
|
|
/**
|
|
* the threads (from the last refreshThreads() call)
|
|
* @type {AndroidThread[]}
|
|
*/
|
|
this._threads = []
|
|
|
|
// path to the the ANDROID_HOME/sources/<api> (only set if it's a valid path)
|
|
this._android_sources_path = '';
|
|
|
|
// number of call stack entries to display above the project source
|
|
this.callStackDisplaySize = 0;
|
|
|
|
/**
|
|
* the fifo queue of evaluations (watches, hover, etc)
|
|
* @type {EvalQueueEntry[]}
|
|
*/
|
|
this._evals_queue = [];
|
|
|
|
// since we want to send breakpoint events, we will assign an id to every event
|
|
// so that the frontend can match events with breakpoints.
|
|
this._breakpointId = 1000;
|
|
// the fifo queue of breakpoints to enable
|
|
this._set_breakpoints_queue = [];
|
|
|
|
this._sourceRefs = { all:[null] }; // hashmap + array of (non-zero) source references
|
|
|
|
// flag to distinguish unexpected disconnection events (initiated from the device) vs user-terminated requests
|
|
this._isDisconnecting = false;
|
|
|
|
// 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.terminate_reason = '';
|
|
|
|
this.session_start = new Date();
|
|
analytics.init(undefined, undefined, undefined, '', require('../package.json'), {}, 'debugger-start');
|
|
|
|
// 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));
|
|
}
|
|
|
|
/**
|
|
* The 'initialize' request is the first request called by the frontend
|
|
* to interrogate the features the debug adapter provides.
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.InitializeResponse} response
|
|
*/
|
|
initializeRequest(response) {
|
|
response.body.exceptionBreakpointFilters = [
|
|
{ label:'All Exceptions', filter:'all', default:false },
|
|
{ label:'Uncaught Exceptions', filter:'uncaught', default:true },
|
|
];
|
|
response.body.supportsConfigurationDoneRequest = true;
|
|
response.body.supportsSetVariable = true;
|
|
response.body.supportsEvaluateForHovers = true;
|
|
response.body.supportsExceptionInfoRequest = true;
|
|
response.body.supportsHitConditionalBreakpoints = true;
|
|
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* @param {string} msg
|
|
*/
|
|
LOG(msg) {
|
|
if (!this.trace) {
|
|
D(msg);
|
|
}
|
|
// VSCode no longer auto-newlines output
|
|
this.sendEvent(new OutputEvent(`${msg}${os.EOL}`));
|
|
}
|
|
|
|
/**
|
|
* @param {string} msg
|
|
*/
|
|
WARN(msg) {
|
|
D(msg = `Warning: ${msg}`);
|
|
// the message will already be sent if trace is enabled
|
|
if (this.trace) {
|
|
return;
|
|
}
|
|
this.sendEvent(new OutputEvent(`${msg}${os.EOL}`));
|
|
}
|
|
|
|
/**
|
|
* @param {string} msg
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.Response} response
|
|
* @param {boolean} silent
|
|
*/
|
|
failRequest(msg, response, silent = false) {
|
|
// yeah, it can happen sometimes...
|
|
if (silent) {
|
|
D(msg); // just log it in debug - don't output it to the client
|
|
} else if (msg) {
|
|
this.WARN(msg);
|
|
}
|
|
if (response) {
|
|
response.success = false;
|
|
this.sendResponse(response);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} requestName
|
|
* @param {number} threadId
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.Response} response
|
|
*/
|
|
failRequestNoThread(requestName, threadId, response) {
|
|
this.failRequest(`${requestName} failed. Thread ${threadId} not found`, response);
|
|
}
|
|
|
|
/**
|
|
* @param {string} requestName
|
|
* @param {number} threadId
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.Response} response
|
|
*/
|
|
failRequestThreadNotSuspended(requestName, threadId, response) {
|
|
this.failRequest(`${requestName} failed. Thread ${threadId} is not suspended`, response);
|
|
}
|
|
|
|
/**
|
|
* @param {string} requestName
|
|
* @param {number} threadId
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.Response} response
|
|
*/
|
|
cancelRequestThreadNotSuspended(requestName, threadId, response) {
|
|
// now that vscode can resume threads before the locals,callstack,etc are retrieved, we only need to cancel the request
|
|
this.failRequest(`${requestName} cancelled. Thread ${threadId} is not suspended`, response, true);
|
|
}
|
|
|
|
/**
|
|
* @param {JavaThreadID|VSCThreadID} id
|
|
* @param {string} [name]
|
|
*/
|
|
getThread(id, name) {
|
|
let thread;
|
|
switch(typeof id) {
|
|
case 'string':
|
|
thread = this._threads.find(t => t && t.threadid === id);
|
|
if (!thread) {
|
|
thread = new AndroidThread(this.dbgr, name, id);
|
|
this._threads[thread.vscode_threadid] = thread;
|
|
}
|
|
break;
|
|
case 'number':
|
|
thread = this._threads[id];
|
|
break;
|
|
}
|
|
return thread;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {'breakpoint'|'step'|'exception'} reason
|
|
* @param {SourceLocation} location
|
|
* @param {DebuggerException} [last_exception]
|
|
*/
|
|
reportStoppedEvent(reason, location, last_exception = null) {
|
|
const thread = this.getThread(location.threadid);
|
|
if (thread.paused) {
|
|
// this thread is already in the paused state - ignore the notification
|
|
thread.paused.reasons.push(reason);
|
|
if (last_exception) {
|
|
thread.paused.last_exception = last_exception;
|
|
}
|
|
return;
|
|
}
|
|
thread.setPaused(reason, location, last_exception);
|
|
this.checkPendingThreadBreaks();
|
|
}
|
|
|
|
async refreshThreads() {
|
|
const thread_ids = await this.dbgr.getJavaThreadIDs();
|
|
const threadinfos = await this.dbgr.getJavaThreadInfos(thread_ids);
|
|
|
|
// configure the thread names
|
|
threadinfos.forEach(threadinfo => {
|
|
const thread = this.getThread(threadinfo.threadid);
|
|
if (typeof thread.name !== 'string') {
|
|
thread.name = threadinfo.name;
|
|
} else if (thread.name !== threadinfo.name) {
|
|
// give the thread a new id for VS code
|
|
// - note: this will invalidate all current variable references for this thread
|
|
delete this._threads[thread.vscode_threadid];
|
|
thread.allocateNewThreadID();
|
|
this._threads[thread.vscode_threadid] = thread;
|
|
thread.name = threadinfo.name;
|
|
}
|
|
});
|
|
|
|
// remove any threads that are no longer in the system
|
|
this._threads.slice().forEach(thread => {
|
|
if (thread) {
|
|
const exists = threadinfos.find(ti => ti.threadid === thread.threadid);
|
|
if (!exists) {
|
|
delete this._threads[thread.vscode_threadid];
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @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}`,
|
|
}
|
|
}
|
|
|
|
extractTargetDeviceID(s) {
|
|
if (!s || typeof s !== 'string') {
|
|
return '';
|
|
}
|
|
// the device picker returns a stringified object
|
|
try {
|
|
const o = JSON.parse(s);
|
|
return o.serial || s;
|
|
} catch {
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* @typedef AndroidAttachArguments
|
|
* @property {number} adbPort
|
|
* @property {string} adbSocket
|
|
* @property {string} appSrcRoot
|
|
* @property {boolean} autoStartADB
|
|
* @property {number} jdwpPort
|
|
* @property {number} processId
|
|
* @property {string} targetDevice
|
|
* @property {boolean} trace
|
|
*
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.AttachResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.AttachRequestArguments & AndroidAttachArguments} args
|
|
*/
|
|
async attachRequest(response, args) {
|
|
this.debug_mode = 'attach';
|
|
if (args && args.trace) {
|
|
this.trace = args.trace;
|
|
onMessagePrint(this.LOG.bind(this));
|
|
}
|
|
D(JSON.stringify({type: 'attach', args, env:process.env}, null, ' '));
|
|
|
|
if (args.targetDevice === 'null') {
|
|
// "null" is returned from the device picker if there's an error or if the
|
|
// user cancels.
|
|
D('targetDevice === "null"');
|
|
this.terminate_reason = "null-targetdevice";
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
return;
|
|
}
|
|
|
|
if (!args.processId) {
|
|
this.LOG(`Attach failed: Missing "processId" property in launch.json`);
|
|
this.terminate_reason = "no-processid";
|
|
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.terminate_reason = "null-attachinfo";
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
return;
|
|
}
|
|
|
|
// set the custom ADB host and port
|
|
if (typeof args.adbSocket === 'string' && args.adbSocket) {
|
|
ADBSocket.HostPort = args.adbSocket;
|
|
} else if (typeof args.adbPort === 'number' && args.adbPort >= 0 && args.adbPort <= 65535) {
|
|
ADBSocket.HostPort = `:${args.adbPort}`;
|
|
}
|
|
|
|
// set the fixed JDWP port number (if any)
|
|
if (typeof args.jdwpPort === 'number' && args.jdwpPort >= 0 && args.jdwpPort <= 65535) {
|
|
Debugger.portManager.fixedport = args.jdwpPort;
|
|
}
|
|
|
|
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.terminate_reason = `init-exception: ${err.message}`;
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let { processId, targetDevice } = attach_info;
|
|
if (!targetDevice) {
|
|
targetDevice = this.extractTargetDeviceID(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 AttachBuildInfo(new Map(this.src_packages.packages));
|
|
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();
|
|
|
|
analytics.event('debug-started', {
|
|
dbg_start: this.session_start.toTimeString(),
|
|
dbg_tz: this.session_start.getTimezoneOffset(),
|
|
dbg_kind: 'attach',
|
|
dbg_device_api: this.device_api_level,
|
|
dbg_emulator: /^emulator/.test(this._device.serial),
|
|
})
|
|
} catch(e) {
|
|
const msg = e.message||e.msg;
|
|
//this.performDisconnect();
|
|
// exceptions use message, adbclient uses msg
|
|
this.LOG('Attach failed: '+(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 using a non-default configuration, also make sure the adbSocket value in your launch.json is correct.');
|
|
}
|
|
if (/ADB|JDWP/.test(msg)) {
|
|
this.LOG('Ensure any instances of Android Studio are closed and ADB is running.');
|
|
}
|
|
// tell the client we're done
|
|
this.terminate_reason = `start-exception: ${msg}`;
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef AndroidLaunchArguments
|
|
* @property {number} adbPort
|
|
* @property {string} adbSocket
|
|
* @property {string[]} amStartArgs
|
|
* @property {string} apkFile
|
|
* @property {string} appSrcRoot
|
|
* @property {boolean} autoStartADB
|
|
* @property {number} callStackDisplaySize
|
|
* @property {number} jdwpPort
|
|
* @property {string} launchActivity
|
|
* @property {string} manifestFile
|
|
* @property {string[]} pmInstallArgs
|
|
* @property {number} postLaunchPause
|
|
* @property {number} processId
|
|
* @property {StaleBuildSetting} staleBuild
|
|
* @property {string} targetDevice
|
|
* @property {boolean} trace
|
|
|
|
* The entry point to the debugger
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.LaunchResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.LaunchRequestArguments & AndroidLaunchArguments} args
|
|
*/
|
|
async launchRequest(response, args) {
|
|
this.debug_mode = 'launch';
|
|
if (args && args.trace) {
|
|
this.trace = args.trace;
|
|
onMessagePrint(this.LOG.bind(this));
|
|
}
|
|
D(JSON.stringify({type: 'launch', args, env:process.env}, null, ' '));
|
|
|
|
if (args.targetDevice === 'null') {
|
|
// "null" is returned from the device picker if there's an error or if the
|
|
// user cancels.
|
|
D('targetDevice === "null"');
|
|
this.terminate_reason = "null-targetdevice";
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
this.manifest_fpn = args.manifestFile;
|
|
this.pm_install_args = args.pmInstallArgs;
|
|
this.am_start_args = args.amStartArgs;
|
|
if (typeof args.callStackDisplaySize === 'number' && args.callStackDisplaySize >= 0)
|
|
this.callStackDisplaySize = args.callStackDisplaySize|0;
|
|
|
|
// we don't allow both amStartArgs and launchActivity to be specified (the launch activity must be included in amStartArgs)
|
|
if (args.amStartArgs && args.launchActivity) {
|
|
this.LOG('amStartArgs and launchActivity options cannot both be specified in the launch configuration.');
|
|
this.terminate_reason = "amStartArgs+launchActivity";
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
return;
|
|
}
|
|
|
|
// set the custom ADB host and port
|
|
if (typeof args.adbSocket === 'string' && args.adbSocket) {
|
|
ADBSocket.HostPort = args.adbSocket;
|
|
} else if (typeof args.adbPort === 'number' && args.adbPort >= 0 && args.adbPort <= 65535) {
|
|
ADBSocket.HostPort = `:${args.adbPort}`;
|
|
}
|
|
|
|
// set the fixed JDWP port number (if any)
|
|
if (typeof args.jdwpPort === 'number' && args.jdwpPort >= 0 && args.jdwpPort <= 65535) {
|
|
Debugger.portManager.fixedport = args.jdwpPort;
|
|
}
|
|
|
|
try {
|
|
// 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" and "apkFile" entries in launch.json');
|
|
this.terminate_reason = `init-exception: ${err.message}`;
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.LOG('Checking build')
|
|
this.apk_file_info = await APKFileInfo.from(args);
|
|
this.checkBuildIsUpToDate(args.staleBuild);
|
|
|
|
// check we have something to launch - we do this again later, but it's a bit better to do it before we start device comms
|
|
let launchActivity = args.launchActivity;
|
|
if (!launchActivity)
|
|
if (!(launchActivity = this.apk_file_info.manifest.launcher))
|
|
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 checkADBStarted(args.autoStartADB !== false);
|
|
const targetDevice = this.extractTargetDeviceID(args.targetDevice);
|
|
this._device = await this.findSuitableDevice(targetDevice, true);
|
|
this._device.adbclient = new ADBClient(this._device.serial);
|
|
|
|
// install the APK we are going to debug
|
|
await this.ensureAPKInstalled();
|
|
|
|
// when we reach here, the app should be installed and ready to be launched
|
|
// - we no longer need the APK file data
|
|
this.apk_file_info.file_data = null;
|
|
|
|
// try and determine the relevant path for the API sources (based upon the API level of the connected device)
|
|
await this.configureAPISourcePath();
|
|
|
|
// launch the app
|
|
await this.startLaunchActivity(args.launchActivity, args.postLaunchPause);
|
|
|
|
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!
|
|
D('Continuing app start');
|
|
this.sendResponse(response);
|
|
await this.dbgr.resume();
|
|
|
|
analytics.event('debug-started', {
|
|
dbg_start: this.session_start.toTimeString(),
|
|
dbg_tz: this.session_start.getTimezoneOffset(),
|
|
dbg_kind: 'debug',
|
|
dbg_device_api: this.device_api_level,
|
|
dbg_emulator: /^emulator/.test(this._device.serial),
|
|
dbg_apk_size: this.apk_file_info.file_size,
|
|
dbg_pkg_name: this.apk_file_info.manifest.package || '',
|
|
})
|
|
|
|
this.LOG('Application started');
|
|
} catch(e) {
|
|
const msg = e.message || e.msg;
|
|
// exceptions use message, adbclient uses msg
|
|
this.LOG('Launch failed: '+(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.');
|
|
}
|
|
if (/ADB|JDWP/.test(msg)) {
|
|
this.LOG('Ensure any instances of Android Studio are closed.');
|
|
}
|
|
// tell the client we're done
|
|
this.terminate_reason = `start-exception: ${msg}`;
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the build is out of date (i.e a source file has been modified since the last build)
|
|
* @param {StaleBuildSetting} staleBuild
|
|
*/
|
|
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) {
|
|
switch (staleBuild) {
|
|
case 'ignore': break;
|
|
case 'stop': throw new Error('Build is not up-to-date');
|
|
case 'warn':
|
|
default: this.WARN('Build is not up-to-date. Source files may not match execution when debugging.'); break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} launchActivity
|
|
* @param {number} postLaunchPause
|
|
*/
|
|
async startLaunchActivity(launchActivity, postLaunchPause) {
|
|
if (!launchActivity) {
|
|
// we're allowed no launchActivity if we have a custom am start command
|
|
if (!this.am_start_args) {
|
|
if (!(launchActivity = this.apk_file_info.manifest.launcher)) {
|
|
throw new Error('No valid launch activity found in AndroidManifest.xml or launch.json');
|
|
}
|
|
}
|
|
}
|
|
|
|
const build = new LaunchBuildInfo(
|
|
new Map(this.src_packages.packages),
|
|
this.apk_file_info.manifest.package,
|
|
launchActivity,
|
|
this.am_start_args,
|
|
postLaunchPause);
|
|
|
|
this.LOG(`Launching on device ${this._device.serial} [API:${this.device_api_level||'?'}]`);
|
|
if (this.am_start_args) {
|
|
this.LOG(`Using custom launch arguments '${this.am_start_args.join(' ')}'`);
|
|
}
|
|
const am_stdout = await this.dbgr.startDebugSession(build, this._device.serial);
|
|
this.LOG(am_stdout);
|
|
}
|
|
|
|
async configureAPISourcePath() {
|
|
const apilevel = await this.getDeviceAPILevel();
|
|
|
|
// look for the android sources folder appropriate for this device
|
|
this._android_sources_path = getAndroidSourcesFolder(apilevel, true);
|
|
}
|
|
|
|
async getDeviceAPILevel() {
|
|
const apilevel = await this._device.adbclient.shell_cmd({command:'getprop ro.build.version.sdk'});
|
|
this.device_api_level = apilevel.trim();
|
|
return this.device_api_level;
|
|
}
|
|
|
|
async ensureAPKInstalled() {
|
|
const installed = await this.isAPKInstalled();
|
|
if (installed) {
|
|
this.LOG('Current build already installed');
|
|
return;
|
|
}
|
|
await this.copyAndInstallAPK();
|
|
}
|
|
|
|
async isAPKInstalled() {
|
|
// retrieve the hash of the installed app (or sha1 utility itself if the app is not installed)
|
|
const query_app_hash = `/system/bin/sha1sum $(pm path ${this.apk_file_info.manifest.package}|grep -o -e '/.*' || echo '/system/bin/sha1sum')`;
|
|
const sha1sum_output = await this._device.adbclient.shell_cmd({command: query_app_hash});
|
|
const installed_hash = sha1sum_output.match(/^[0-9a-fA-F]*/)[0].toLowerCase();
|
|
|
|
// does the installed apk hash match the content hash? if, so we don't need to install the app
|
|
return installed_hash === this.apk_file_info.content_hash;
|
|
}
|
|
|
|
async copyAndInstallAPK() {
|
|
// copy the file to the device
|
|
this.LOG('Deploying current build...');
|
|
const device_apk_fpn = '/data/local/tmp/debug.apk';
|
|
await this._device.adbclient.push_file({
|
|
pathname: device_apk_fpn,
|
|
data: this.apk_file_info.file_data,
|
|
mtime: (Date.now() / 1000) | 0,
|
|
perms: 0o100664,
|
|
})
|
|
// send the install command
|
|
this.LOG('Installing...');
|
|
const pm_install_args = Array.isArray(this.pm_install_args) ? this.pm_install_args.join(' ') : '-r';
|
|
const command = `pm install ${pm_install_args} ${device_apk_fpn}`;
|
|
D(command);
|
|
const stdout = await this._device.adbclient.shell_cmd({
|
|
command,
|
|
})
|
|
// failures:
|
|
// pkg: x-y-z.apk
|
|
// Failure [INSTALL_FAILED_OLDER_SDK]
|
|
const failure_match = stdout.match(/Failure\s+\[([^\]]+)\]/g);
|
|
if (failure_match) {
|
|
throw new Error('Installation failed. ' + failure_match[0]);
|
|
}
|
|
// now the 'pm install' command can have user-defined arguments, we must check that the command
|
|
// is not rejected because of bad values
|
|
const m = stdout.match(/^java.lang.IllegalArgumentException:.+/m);
|
|
if (m) {
|
|
throw new Error('Installation failed. ' + m[0]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} target_deviceid
|
|
* @param {boolean} show_progress
|
|
*/
|
|
async findSuitableDevice(target_deviceid, show_progress) {
|
|
show_progress && this.LOG('Searching for devices...');
|
|
const devices = await this.dbgr.listConnectedDevices()
|
|
show_progress && this.LOG(`Found ${devices.length} device${devices.length===1?'':'s'}`);
|
|
|
|
let reject;
|
|
if (devices.length === 0) {
|
|
reject = 'No devices are connected';
|
|
} else if (target_deviceid) {
|
|
// check (only one of) the requested device is present
|
|
const matching_devices = devices.filter(d => d.serial === target_deviceid);
|
|
switch(matching_devices.length) {
|
|
case 0:
|
|
reject = `Target device: '${target_deviceid}' is not connected. Connect it or specify an alternate target device in launch.json`;
|
|
break;
|
|
case 1:
|
|
return matching_devices[0];
|
|
default:
|
|
reject = `Target device: '${target_deviceid}' has multiple candidates. Connect a single device or specify an alternate target device in launch.json`;
|
|
break;
|
|
}
|
|
} else if (devices.length === 1) {
|
|
// no specific target device and only one device is connected to adb - use it
|
|
return devices[0];
|
|
} else {
|
|
// more than one device and no specific target - fail the launch
|
|
reject = `Multiple devices are connected and no target device is specified in launch.json`;
|
|
// be nice and list the devices so the user can easily configure
|
|
devices.forEach(d => this.LOG(`\t${d.serial}\t${d.status}`));
|
|
}
|
|
throw new Error(reject);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ConfigurationDoneResponse} response
|
|
*/
|
|
configurationDoneRequest(response) {
|
|
D('configurationDoneRequest');
|
|
this.waitForConfigurationDone();
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
onDebuggerDisconnect() {
|
|
// called when we manually disconnect, or from an unexpected disconnection (USB cable disconnect, etc)
|
|
if (!this._isDisconnecting) {
|
|
D('Unexpected disconnection');
|
|
// this is a surprise disconnect (initiated from the device) - tell the client we're done
|
|
this.LOG(`Device disconnected`);
|
|
this.sendEvent(new TerminatedEvent(false));
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.DisconnectResponse} response
|
|
*/
|
|
async disconnectRequest(response) {
|
|
D('disconnectRequest');
|
|
this._isDisconnecting = true;
|
|
analytics.event('debug-end', {
|
|
dbg_elapsed: Math.trunc((Date.now() - this.session_start.getTime())/1e3),
|
|
dbg_kind: this.debug_mode,
|
|
dbg_term_reason: this.terminate_reason,
|
|
});
|
|
if (this.debuggerAttached) {
|
|
try {
|
|
if (this.debug_mode === 'launch') {
|
|
await this.dbgr.forceStop();
|
|
this.LOG(`Debugger stopped`);
|
|
} else {
|
|
await this.dbgr.disconnect();
|
|
this.LOG(`Debugger detached`);
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
onBreakpointStateChange(e) {
|
|
D('onBreakpointStateChange');
|
|
e.breakpoints.forEach(javabp => {
|
|
// if there's no associated vsbp we're deleting it, so just ignore the update
|
|
if (!javabp.vsbp) return;
|
|
const verified = !!javabp.state.match(/set|enabled/);
|
|
javabp.vsbp.verified = verified;
|
|
javabp.vsbp.message = null;
|
|
this.sendEvent(new BreakpointEvent('changed', javabp.vsbp));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called by the debugger in response to a JDWP breakpoint hit event
|
|
* @param {JavaBreakpointEvent} e
|
|
*/
|
|
onBreakpointHit(e) {
|
|
// if we step into a breakpoint, both onBreakpointHit and onStep will be called
|
|
D(`Breakpoint hit: ${e.stoppedLocation}`);
|
|
this.reportStoppedEvent("breakpoint", e.stoppedLocation);
|
|
}
|
|
|
|
/**
|
|
* Called when the user requests a change to breakpoints in a source file
|
|
* Note: all breakpoints in a file are always sent in args, even if they are not changing
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SetBreakpointsResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SetBreakpointsArguments} args
|
|
*/
|
|
async setBreakPointsRequest(response, args) {
|
|
const source_filename = args.source && args.source.path;
|
|
D('setBreakPointsRequest: ' + source_filename);
|
|
|
|
const unverified_breakpoint = (src_bp,reason) => {
|
|
const bp = new Breakpoint(false, src_bp.line);
|
|
bp['id'] = ++this._breakpointId;
|
|
bp['message'] = reason;
|
|
return bp;
|
|
}
|
|
|
|
const sendBPResponse = (response, breakpoints) => {
|
|
D('setBreakPointsRequest response ' + JSON.stringify(breakpoints.map(bp => bp.verified)));
|
|
response.body = {
|
|
breakpoints,
|
|
};
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
// the file must lie inside one of the source packages we found (and it must be have a .java extension)
|
|
const srcfolder = path.dirname(source_filename);
|
|
const package_infos = [...this.src_packages.packages.values()];
|
|
let pkginfo = package_infos.find(pi => pi.package_path === srcfolder);
|
|
|
|
// if we didn't find an exact path match, look for a case-insensitive match
|
|
if (!pkginfo) {
|
|
pkginfo = package_infos.find(pi => pi.package_path.localeCompare(srcfolder, undefined, { sensitivity: 'base' }) === 0);
|
|
}
|
|
|
|
// if it's not in our source packages, check if it's in the Android source file cache
|
|
if (!pkginfo && is_subpath_of(source_filename, this._android_sources_path)) {
|
|
// create a fake pkginfo to use to construct the bp
|
|
pkginfo = new PackageInfo(this._android_sources_path, '', [], '', '');
|
|
}
|
|
if (!pkginfo || !hasValidSourceFileExtension(source_filename)) {
|
|
// source file is not a java file or is outside of the known source packages
|
|
// just send back a list of unverified breakpoints
|
|
sendBPResponse(response, args.breakpoints.map(bp => unverified_breakpoint(bp, 'The breakpoint location is not valid')));
|
|
return;
|
|
}
|
|
|
|
// our debugger requires a relative fpn beginning with / , rooted at the java source base folder
|
|
// - it should look like: /some/package/name/abc.java
|
|
const relative_fpn = source_filename.slice(pkginfo.srcroot.match(/^(.*?)[\\/]?$/)[1].length).replace(/\\/g,'/');
|
|
|
|
// delete any existing breakpoints not in the list
|
|
const src_line_nums = args.breakpoints.map(bp => bp.line);
|
|
const deleted_breakpoints = this.dbgr.findBreakpoints(
|
|
javabp => (javabp.srcfpn === relative_fpn) && !src_line_nums.includes(javabp.linenum)
|
|
);
|
|
deleted_breakpoints.forEach(bp => bp.vsbp = null);
|
|
this.dbgr.removeBreakpoints(deleted_breakpoints);
|
|
|
|
// setting a debugger bp is now asynchronous, so we do this as an orderly queue
|
|
const bp_queue_len = this._set_breakpoints_queue.push({args,response,relative_fpn});
|
|
if (bp_queue_len === 1) {
|
|
do {
|
|
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(response, javabp_arr.map(javabp => javabp.vsbp));
|
|
// .. and do the next one
|
|
this._set_breakpoints_queue.shift();
|
|
} while (this._set_breakpoints_queue.length);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SourceBreakpoint[]} breakpoints
|
|
* @param {string} relative_fpn
|
|
*/
|
|
async setupBreakpointsInFile(breakpoints, relative_fpn) {
|
|
const java_breakpoints = [];
|
|
for (let idx = 0; idx < breakpoints.length; idx++) {
|
|
const src_bp = breakpoints[idx];
|
|
const dbgline = this.convertClientLineToDebugger(src_bp.line);
|
|
const options = new BreakpointOptions();
|
|
if (src_bp.hitCondition) {
|
|
// the hit condition is an expression that requires evaluation
|
|
// until we get more comprehensive evaluation support, just allow integer literals
|
|
const m = src_bp.hitCondition.match(/^\s*(?:0x([0-9a-f]+)|0b([01]+)|0*(\d+([e]\+?\d+)?))\s*$/i);
|
|
if (m) {
|
|
const hitcount = m[3] ? parseFloat(m[3]) : m[2] ? parseInt(m[2],2) : parseInt(m[1],16);
|
|
if ((hitcount > 0) && (hitcount <= 0x7fffffff)) {
|
|
options.hitcount = hitcount;
|
|
}
|
|
}
|
|
}
|
|
const javabp = await this.dbgr.setBreakpoint(relative_fpn, dbgline, options);
|
|
if (!javabp.vsbp) {
|
|
// state is one of: set,notloaded,enabled,removed
|
|
const verified = !!javabp.state.match(/set|enabled/);
|
|
const bp = new Breakpoint(verified, this.convertDebuggerLineToClient(dbgline));
|
|
// the breakpoint *must* have an id field or it won't update properly
|
|
bp['id'] = ++this._breakpointId;
|
|
if (javabp.state === 'notloaded')
|
|
bp['message'] = 'The runtime hasn\'t loaded this code location';
|
|
javabp.vsbp = bp;
|
|
}
|
|
javabp.vsbp.order = idx;
|
|
java_breakpoints.push(javabp);
|
|
}
|
|
return java_breakpoints;
|
|
};
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SetExceptionBreakpointsResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SetExceptionBreakpointsArguments} args
|
|
*/
|
|
async setExceptionBreakPointsRequest(response, args) {
|
|
await this.dbgr.clearBreakOnExceptions();
|
|
switch(true) {
|
|
case args.filters.includes('all'):
|
|
await this.dbgr.setBreakOnExceptions('both');
|
|
break;
|
|
case args.filters.includes('uncaught'):
|
|
await this.dbgr.setBreakOnExceptions('uncaught');
|
|
break;
|
|
}
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ThreadsResponse} response
|
|
*/
|
|
async threadsRequest(response) {
|
|
if (!this._threads.length) {
|
|
try {
|
|
await this.refreshThreads();
|
|
} catch (e) {
|
|
response.success = false;
|
|
this.sendResponse(response);
|
|
return;
|
|
}
|
|
}
|
|
D('threadsRequest: ' + this._threads.length);
|
|
response.body = {
|
|
threads: this._threads
|
|
.filter(x => x)
|
|
.map(t => {
|
|
const javaid = parseInt(t.threadid, 16);
|
|
return new Thread(t.vscode_threadid, `Thread (id:${javaid}) ${t.name||'<unnamed>'}`);
|
|
})
|
|
};
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Returns a stack trace for the given threadId
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.StackTraceResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.StackTraceArguments} args
|
|
*/
|
|
async stackTraceRequest(response, args) {
|
|
D(`stackTraceRequest thread:${args.threadId}`);
|
|
// only retrieve the stack if the thread is paused
|
|
const thread = this.getThread(args.threadId);
|
|
if (!thread) return this.failRequestNoThread('Stack trace', args.threadId, response);
|
|
if (!thread.paused) return this.cancelRequestThreadNotSuspended('Stack trace', args.threadId, response);
|
|
|
|
try {
|
|
// retrieve the (stack) frames from the debugger
|
|
const frames = await this.dbgr.getFrames(thread.threadid);
|
|
// ensure that the line-tables for all the methods are loaded
|
|
await Promise.all(frames.map(f => this.dbgr._ensureMethodLines(f.method)));
|
|
|
|
const startFrame = typeof args.startFrame === 'number' ? args.startFrame : 0;
|
|
const maxLevels = typeof args.levels === 'number' ? args.levels : frames.length-startFrame;
|
|
const endFrame = Math.min(startFrame + maxLevels, frames.length);
|
|
let stack = [];
|
|
let totalFrames = frames.length;
|
|
let highest_known_source = 0;
|
|
const android_src_path = this._android_sources_path || '{Android SDK}';
|
|
for (let i = startFrame; (i < endFrame) && thread.paused; i++) {
|
|
// the stack_frame_id must be unique across all threads
|
|
const stack_frame = thread.createStackFrameVariable(frames[i], i);
|
|
const name = `${frames[i].method.owningclass.name}.${frames[i].method.name}`;
|
|
const type = frames[i].method.owningclass.type;
|
|
if (!(type instanceof JavaClassType)) {
|
|
totalFrames--;
|
|
continue; // sanity check - the call stack must be in a class type
|
|
}
|
|
const pkginfo = this.src_packages.packages.get(type.package);
|
|
const srcloc = this.dbgr.frameToSourceLocation(frames[i]);
|
|
if (!srcloc && !pkginfo) {
|
|
totalFrames--;
|
|
continue; // ignore frames which have no location (they're probably synthetic)
|
|
}
|
|
const linenum = srcloc && this.convertDebuggerLineToClient(srcloc.linenum);
|
|
const sourcefile = frames[i].method.owningclass.src.sourcefile || (type.signature.match(/([^\/$]+)[;$]/)[1]+'.java');
|
|
let srcRefId = 0;
|
|
let srcInfo;
|
|
if (!pkginfo) {
|
|
const sig = type.signature;
|
|
srcInfo = this._sourceRefs[sig];
|
|
if (!srcInfo) {
|
|
this._sourceRefs.all.push(srcInfo = {
|
|
id: this._sourceRefs.all.length,
|
|
signature:sig,
|
|
filepath:path.join(android_src_path,type.package.replace(/[.]/g,path.sep), sourcefile),
|
|
content:null
|
|
});
|
|
this._sourceRefs[sig] = srcInfo;
|
|
}
|
|
srcRefId = srcInfo.id;
|
|
}
|
|
// if this is not a known package, check if android sources is valid
|
|
// - if it is, return the expected path - VSCode will auto-load it
|
|
// - if not, set the path to null and a sourceRequest will be made.
|
|
const srcpath = pkginfo ? path.join(pkginfo.package_path,sourcefile)
|
|
: this._android_sources_path ? srcInfo.filepath
|
|
: null;
|
|
const src = new Source(sourcefile, srcpath, srcpath ? 0 : srcRefId);
|
|
pkginfo && (highest_known_source=i);
|
|
// we don't support column number when reporting source locations (because JDWP only supports line-granularity)
|
|
// but in order to get the Exception UI to show, we must have a non-zero column
|
|
const colnum = (!i && thread.paused.last_exception && thread.paused.reasons[0]==='exception') ? 1 : 0;
|
|
stack.push(new StackFrame(stack_frame.variableReference, name, src, linenum, colnum));
|
|
}
|
|
// trim the stack to exclude calls above the known sources
|
|
if (this.callStackDisplaySize > 0) {
|
|
stack = stack.slice(0,highest_known_source+this.callStackDisplaySize);
|
|
totalFrames = stack.length;
|
|
}
|
|
// return the frames
|
|
response.body = {
|
|
stackFrames: stack,
|
|
totalFrames: totalFrames,
|
|
};
|
|
this.sendResponse(response);
|
|
} catch(e) {
|
|
return this.failRequest('No call stack is available', response);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ScopesResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ScopesArguments} args
|
|
*/
|
|
async scopesRequest(response, args) {
|
|
D(`scopesRequest frame:${args.frameId}`);
|
|
const threadId = AndroidThread.variableRefToThreadId(args.frameId);
|
|
const thread = this.getThread(threadId);
|
|
if (!thread) return this.failRequestNoThread('Scopes',threadId, response);
|
|
if (!thread.paused) return this.cancelRequestThreadNotSuspended('Scopes', threadId, response);
|
|
|
|
const scopes = [new Scope("Local", args.frameId, false)];
|
|
response.body = {
|
|
scopes,
|
|
};
|
|
|
|
const last_exception = thread.paused.last_exception;
|
|
if (!last_exception) {
|
|
this.sendResponse(response);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
last_exception.scopeRef = args.frameId + 1;
|
|
const scope = new Scope(`Exception: ${last_exception.exceptionValue.type.typename}`, last_exception.scopeRef, false);
|
|
// put the exception first - otherwise it can get lost if there's a lot of locals
|
|
scopes.unshift(scope);
|
|
} catch(e) {
|
|
}
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SourceResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SourceArguments} args
|
|
*/
|
|
sourceRequest(response, args) {
|
|
D(`sourceRequest: ${args.sourceReference}`);
|
|
const content =
|
|
`/*
|
|
The source for this class is unavailable.
|
|
|
|
Source files for each Android API level can be downloaded using the Android SDK Manager.
|
|
|
|
To display the file, you must download the sources matching the API level of your device or
|
|
emulator and ensure that your ANDROID_HOME environment path is configured correctly.
|
|
*/
|
|
`;
|
|
// don't actually attempt to load the file here - just recheck to see if the sources
|
|
// path is valid yet.
|
|
this._android_sources_path = getAndroidSourcesFolder(this.device_api_level, true);
|
|
|
|
response.body = { content };
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.VariablesResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.VariablesArguments} args
|
|
*/
|
|
async variablesRequest(response, args) {
|
|
D(`variablesRequest variablesReference:${args.variablesReference}`);
|
|
const threadId = AndroidThread.variableRefToThreadId(args.variablesReference);
|
|
const thread = this.getThread(threadId);
|
|
if (!thread) return this.failRequestNoThread('Variables',threadId, response);
|
|
if (!thread.paused) return this.cancelRequestThreadNotSuspended('Variables',threadId, response);
|
|
|
|
let variables = [];
|
|
const stack_frame = thread.findStackFrame(args.variablesReference);
|
|
const vref = args.variablesReference % 1e6;
|
|
switch(vref) {
|
|
case 0: // frame scope reference
|
|
case 1: // exception scope reference
|
|
variables = await stack_frame.getLocalVariables();
|
|
if (vref === 1) {
|
|
variables = [stack_frame.makeVariableValue(thread.paused.last_exception.exceptionValue)];
|
|
variables = await stack_frame.getExpandableValues(variables[0].variablesReference);
|
|
}
|
|
break;
|
|
default: {
|
|
// variable reference for an expandable entry
|
|
variables = await stack_frame.getExpandableValues(args.variablesReference);
|
|
break;
|
|
}
|
|
}
|
|
response.body = {
|
|
variables,
|
|
};
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Choose a stopped thread to show in VSCode.
|
|
* This function prioritises multiple stopped threads.
|
|
*/
|
|
checkPendingThreadBreaks() {
|
|
// threads that are currently mid-step
|
|
const stepping_thread = this._threads.find(t => t && t.stepTimeout);
|
|
// threads that are currently paused
|
|
const paused_threads = this._threads.filter(t => t && t.paused);
|
|
// paused threads that we've notified VSCode about
|
|
const stopped_thread = paused_threads.find(t => t.paused.stoppedEvent);
|
|
if (stopped_thread || stepping_thread || !paused_threads.length) {
|
|
// we already have a stopped thread, or
|
|
// we are waiting for the stepping thread to complete its step, or
|
|
// there are no paused threads (i.e all threads are currently running)
|
|
return;
|
|
}
|
|
|
|
// prioritise any stepped thread (if it's stopped) - this allows the user to step through
|
|
// code without bouncing between different threads
|
|
let thread;
|
|
const paused_step_thread = paused_threads.find(t => t.paused.reasons.includes("step"));
|
|
if (paused_step_thread) {
|
|
thread = paused_step_thread;
|
|
} else {
|
|
// if there's no paused step thread, choose the earliest paused thread
|
|
paused_threads.sort((a,b) => a.paused.when - b.paused.when);
|
|
thread = paused_threads[0];
|
|
}
|
|
// if the break was due to a breakpoint and it has since been removed, just silently resume the thread
|
|
if (thread.paused.reasons.length === 1 && thread.paused.reasons[0] === 'breakpoint') {
|
|
const { linenum, qtype} = thread.paused.location;
|
|
const bp = this.dbgr.breakpoints.byID.get(`${linenum}:${qtype}`);
|
|
if (!bp) {
|
|
this.continueThread(thread);
|
|
return;
|
|
}
|
|
}
|
|
// tell VSCode about the stopped thread
|
|
const event = new StoppedEvent(thread.paused.reasons[0], thread.vscode_threadid, thread.paused.last_exception && "Exception thrown");
|
|
thread.paused.stoppedEvent = event;
|
|
this.sendEvent(event);
|
|
}
|
|
|
|
/**
|
|
* @param {AndroidThread} thread
|
|
*/
|
|
async continueThread(thread) {
|
|
thread.paused = null;
|
|
this.checkPendingThreadBreaks();
|
|
await this.dbgr.resumeThread(thread.threadid);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ContinueResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ContinueArguments} args
|
|
*/
|
|
continueRequest(response, args) {
|
|
D(`Continue thread:${args.threadId}`);
|
|
|
|
const thread = this.getThread(args.threadId);
|
|
if (!thread) return this.failRequestNoThread('Continue', args.threadId, response);
|
|
if (!thread.paused) return this.failRequestThreadNotSuspended('Continue', args.threadId, response);
|
|
|
|
this.sendResponse(response);
|
|
this.continueThread(thread);
|
|
}
|
|
|
|
/**
|
|
* Called by the debugger after a step operation has completed
|
|
*/
|
|
onStep(e) {
|
|
// if we step into a breakpoint, both onBreakpointHit and onStep will be called
|
|
D(`step hit: ${e.stoppedLocation}`);
|
|
this.reportStoppedEvent("step", e.stoppedLocation);
|
|
}
|
|
|
|
/**
|
|
* Called by the user to start a step operation
|
|
* @param {DebuggerStepType} which
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.NextResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.NextArguments} args
|
|
*/
|
|
doStep(which, response, args) {
|
|
D(`step ${which}`);
|
|
|
|
const thread = this.getThread(args.threadId);
|
|
if (!thread) return this.failRequestNoThread('Step', args.threadId, response);
|
|
if (!thread.paused) return this.failRequestThreadNotSuspended('Step', args.threadId, response);
|
|
|
|
thread.paused = null;
|
|
|
|
this.sendResponse(response);
|
|
|
|
// we time the step - if it takes too long to complete, we switch to any other threads that are waiting
|
|
thread.stepTimeout = setTimeout(() => {
|
|
D(`Step timeout on thread: ${thread.threadid}`);
|
|
thread.stepTimeout = null;
|
|
this.checkPendingThreadBreaks();
|
|
}, 2000);
|
|
|
|
this.dbgr.step(which, thread.threadid);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.NextResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.StepInArguments} args
|
|
*/
|
|
stepInRequest(response, args) {
|
|
this.doStep('in', response, args);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.NextResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.NextArguments} args
|
|
*/
|
|
nextRequest(response, args) {
|
|
this.doStep('over', response, args);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.NextResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.StepOutArguments} args
|
|
*/
|
|
stepOutRequest(response, args) {
|
|
this.doStep('out', response, args);
|
|
}
|
|
|
|
/**
|
|
* Called by the debugger if an exception event is triggered
|
|
* @param {JavaExceptionEvent} e
|
|
*/
|
|
async onException(e) {
|
|
// it's possible for the debugger to send multiple exception notifications for the same thread, depending on the package filters
|
|
D(`exception hit: ${e.throwlocation}`);
|
|
const thread_id = e.throwlocation.threadid;
|
|
// retrieve the exception object
|
|
const ex_value = await this.dbgr.getExceptionValue(thread_id, e.event.exception)
|
|
const last_exception = new DebuggerException(ex_value, thread_id);
|
|
this.reportStoppedEvent("exception", e.throwlocation, last_exception);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ExceptionInfoResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.ExceptionInfoArguments} args
|
|
*/
|
|
async exceptionInfoRequest(response, args) {
|
|
D(`exceptionInfoRequest: ${args.threadId}`);
|
|
const thread = this.getThread(args.threadId);
|
|
if (!thread) return this.failRequestNoThread('Exception info', args.threadId, response);
|
|
if (!thread.paused) return this.cancelRequestThreadNotSuspended('Exception info', args.threadId, response);
|
|
if (!thread.paused.last_exception) return this.failRequest('No exception available', response);
|
|
|
|
// we must wait for the exception object to be retreived as a local (along with the message field)
|
|
const ex_value = thread.paused.last_exception.exceptionValue;
|
|
const message = ex_value.data.msg.string;
|
|
|
|
response.body = {
|
|
/** ID of the exception that was thrown. */
|
|
exceptionId: ex_value.type.typename,
|
|
/** Descriptive text for the exception provided by the debug adapter. */
|
|
description: `${os.EOL}${message}`,
|
|
/** Mode that caused the exception notification to be raised. */
|
|
//'never' | 'always' | 'unhandled' | 'userUnhandled';
|
|
breakMode: 'always',
|
|
/** Detailed information about the exception. */
|
|
details: {
|
|
/** Message contained in the exception. */
|
|
message,
|
|
/** Short type name of the exception object. */
|
|
typeName: ex_value.type.typename,
|
|
/** Fully-qualified type name of the exception object. */
|
|
fullTypeName: ex_value.type.fullyQualifiedName(),
|
|
/** Optional expression that can be evaluated in the current scope to obtain the exception object. */
|
|
//evaluateName: "evaluateName",
|
|
/** Stack trace at the time the exception was thrown. */
|
|
//stackTrace: "stackTrace",
|
|
/** Details of the exception contained by this exception, if any. */
|
|
//innerException: [],
|
|
}
|
|
}
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Called by the debugger if a thread start/end event is triggered
|
|
*/
|
|
async onThreadChange(e) {
|
|
D(`thread ${e.state}: ${e.threadid}(${parseInt(e.threadid,16)})`);
|
|
switch(e.state) {
|
|
case 'start': {
|
|
try {
|
|
const threadinfos = await this.dbgr.getJavaThreadInfos([e.threadid]);
|
|
const t = this.getThread(threadinfos[0].threadid, threadinfos[0].name);
|
|
this.sendEvent(new ThreadEvent('started', t.vscode_threadid));
|
|
} catch(e) {
|
|
}
|
|
break;
|
|
}
|
|
case 'end':
|
|
const t = this._threads.find(t => t && t.threadid === e.threadid);
|
|
if (t) {
|
|
if (t.stepTimeout) {
|
|
clearTimeout(t.stepTimeout);
|
|
t.stepTimeout = null;
|
|
}
|
|
delete this._threads[t.vscode_threadid];
|
|
this.sendEvent(new ThreadEvent('exited', t.vscode_threadid));
|
|
this.checkPendingThreadBreaks(); // in case we were stepping this thread
|
|
}
|
|
break;
|
|
}
|
|
this.dbgr.resumeThread(e.threadid);
|
|
}
|
|
|
|
/**
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SetVariableResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.SetVariableArguments} args
|
|
*/
|
|
async setVariableRequest(response, args) {
|
|
|
|
const threadId = AndroidThread.variableRefToThreadId(args.variablesReference);
|
|
const thread = this.getThread(threadId);
|
|
if (!thread) return this.failRequestNoThread('Set variable', threadId, response);
|
|
if (!thread.paused) return this.failRequestThreadNotSuspended('Set variable', threadId, response);
|
|
|
|
try {
|
|
// retrieve the stack frame the variable belongs to
|
|
const stack_frame = thread.findStackFrame(args.variablesReference);
|
|
// evaluate the expression
|
|
const locals = await stack_frame.getLocals();
|
|
const { value } = await evaluate(args.value, thread, locals, this.dbgr);
|
|
// update the variable
|
|
const vsvar = await stack_frame.setVariableValue(args.variablesReference, args.name, value);
|
|
response.body = {
|
|
value: vsvar.value,
|
|
type: vsvar.type,
|
|
variablesReference: vsvar.variablesReference,
|
|
};
|
|
} catch (e) {
|
|
response.success = false;
|
|
response.message = e.message;
|
|
}
|
|
this.sendResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Called by VSCode to perform watch, console and hover evaluations
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.EvaluateResponse} response
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.EvaluateArguments} args
|
|
*/
|
|
async evaluateRequest(response, args) {
|
|
|
|
// Some notes to remember:
|
|
// annoyingly, during stepping, the step can complete before the resume has called evaluateRequest on watches.
|
|
// The order can go: doStep(running=true),onStep(running=false),evaluateRequest(),evaluateRequest()
|
|
// so we end up evaluating twice...
|
|
// also annoyingly, this method is called before the locals in the current stack frame are evaluated
|
|
// and even more annoyingly, Android (or JDWP) seems to get confused on the first request when we're retrieving multiple values, fields, etc
|
|
// so we have to queue them or we end up with strange results
|
|
|
|
// look for a matching entry in the list (other than at index:0)
|
|
const previdx = this._evals_queue.findIndex(e => e.expression === args.expression);
|
|
if (previdx > 0) {
|
|
// if we find a match, immediately fail the old one and queue the new one
|
|
const prev = this._evals_queue.splice(previdx,1)[0];
|
|
prev.response.success = false;
|
|
prev.response.message = '(evaluating)';
|
|
this.sendResponse(prev.response);
|
|
}
|
|
|
|
let eval_info;
|
|
if (args.frameId) {
|
|
const threadId = AndroidThread.variableRefToThreadId(args.frameId);
|
|
const thread = this.getThread(threadId);
|
|
if (!thread) return this.failRequestNoThread('Evaluate',threadId, response);
|
|
if (!thread.paused) return this.failRequestThreadNotSuspended('Evaluate',threadId, response);
|
|
const stack_frame = thread.findStackFrame(args.frameId);
|
|
const locals = await stack_frame.getLocals();
|
|
eval_info = new EvalQueueEntry(args.expression, response, locals, stack_frame, thread);
|
|
} else {
|
|
// if there's no frameId, we are being asked to evaluate the value in the 'global' context.
|
|
// This is a problem because there's no associated stack frame, so we include any locals in the evaluation.
|
|
// We still want the user to be able to call into the runtime to create new objects, evaluate static fields, etc so
|
|
// we choose an arbitrary paused thread to execute on (without this, the only evaluations we could perform
|
|
// would require primitive literals)
|
|
const thread = this._threads.find(t => t && t.paused);
|
|
if (!thread) return this.failRequest(`No threads are paused`, response);
|
|
eval_info = new EvalQueueEntry(args.expression, response, [], thread.getGlobalVariableManager(), thread);
|
|
}
|
|
|
|
const queue_len = this._evals_queue.push(eval_info);
|
|
if (queue_len > 1) {
|
|
return;
|
|
}
|
|
|
|
while (this._evals_queue.length > 0) {
|
|
const { expression, response, locals, var_manager, thread } = this._evals_queue[0];
|
|
try {
|
|
const { value, display_format } = await evaluate(expression, thread, locals, this.dbgr, { allowFormatSpecifier:true });
|
|
const v = var_manager.makeVariableValue(value, display_format);
|
|
response.body = {
|
|
result: v.value,
|
|
variablesReference: v.variablesReference|0
|
|
};
|
|
} catch (e) {
|
|
response.success = false;
|
|
response.message = e.message;
|
|
}
|
|
this.sendResponse(response);
|
|
this._evals_queue.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
class EvalQueueEntry {
|
|
/**
|
|
* @param {string} expression
|
|
* @param {import('@vscode/debugprotocol').DebugProtocol.EvaluateResponse} response
|
|
* @param {DebuggerValue[]} locals
|
|
* @param {VariableManager} var_manager
|
|
* @param {AndroidThread} thread
|
|
*/
|
|
constructor(expression, response, locals, var_manager, thread) {
|
|
this.expression = expression;
|
|
this.response = response;
|
|
this.locals = locals;
|
|
this.var_manager = var_manager;
|
|
this.thread = thread;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} p
|
|
*/
|
|
function ensure_path_end_slash(p) {
|
|
return p + (/[\\/]$/.test(p) ? '' : path.sep);
|
|
}
|
|
|
|
/**
|
|
* @param {string} fullpath
|
|
* @param {string} subpath
|
|
*/
|
|
function is_subpath_of(fullpath, subpath) {
|
|
if (!subpath || !fullpath) {
|
|
return false;
|
|
}
|
|
subpath = ensure_path_end_slash(subpath);
|
|
return fullpath.slice(0,subpath.length) === subpath;
|
|
}
|
|
|
|
DebugSession.run(AndroidDebugSession);
|