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, DebuggerValue, JavaBreakpointEvent, JavaClassType, JavaExceptionEvent, LaunchBuildInfo, SourceLocation } = 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 { VariableManager } = require('./variable-manager'); const analytics = require('../langserver/analytics'); 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 = ''; // packages we found in the source tree this.src_packages = { last_src_modified: 0, /** @type {Map} */ 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/ (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||''}`); }) }; 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);