'use strict' const { DebugSession, InitializedEvent, ExitedEvent, TerminatedEvent, StoppedEvent, BreakpointEvent, OutputEvent, Event, Thread, StackFrame, Scope, Source, Handles, Breakpoint } = require('vscode-debugadapter'); const DebugProtocol = { //require('vscode-debugprotocol'); /** Arguments for 'launch' request. */ LaunchRequestArguments: class { /** If noDebug is true the launch request should launch the program without enabling debugging. */ get noDebug() { return false } } } // node and external modules const crypto = require('crypto'); const dom = require('xmldom').DOMParser; const fs = require('fs'); const path = require('path'); const xpath = require('xpath'); // our stuff const { ADBClient } = require('./adbclient'); const { Debugger } = require('./debugger'); const $ = require('./jq-promise'); const { D, isEmptyObject } = require('./util'); const ws_proxy = require('./wsproxy').proxy.Server(6037, 5037); // arbitrary precision helper class for 64 bit numbers const NumberBaseConverter = { // Adds two arrays for the given base (10 or 16), returning the result. add(x, y, base) { var z = [], n = Math.max(x.length, y.length), carry = 0, i = 0; while (i < n || carry) { var xi = i < x.length ? x[i] : 0; var yi = i < y.length ? y[i] : 0; var zi = carry + xi + yi; z.push(zi % base); carry = Math.floor(zi / base); i++; } return z; }, // Returns a*x, where x is an array of decimal digits and a is an ordinary // JavaScript number. base is the number base of the array x. multiplyByNumber(num, x, base) { if (num < 0) return null; if (num == 0) return []; var result = [], power = x; for(;;) { if (num & 1) { result = this.add(result, power, base); } num = num >> 1; if (num === 0) return result; power = this.add(power, power, base); } }, twosComplement(str, base) { const invdigits = str.split('').map(c => 15 - parseInt(c,base)).reverse(); const negdigits = this.add(invdigits, [1], base).slice(0,str.length); return negdigits.reverse().map(d => d.toString(base)).join(''); }, convertBase(str, fromBase, toBase) { var digits = str.split('').map(d => parseInt(d,fromBase)).reverse(); var outArray = [], power = [1]; for (var i = 0; i < digits.length; i++) { if (digits[i]) { outArray = this.add(outArray, this.multiplyByNumber(digits[i], power, toBase), toBase); } power = this.multiplyByNumber(fromBase, power, toBase); } return outArray.reverse().map(d => d.toString(toBase)).join(''); }, hexToDec(hexstr, signed) { var res, isneg = /^[^0-7]/.test(hexstr); if (hexstr.match(/^0*(.+)$/)[1].length*4 < 52) { // less than 52 bits - just use parseInt res = parseInt(hexstr, 16); if (signed && isneg) res = -res; return res.toString(10); } if (isneg) { hexstr = NumberBaseConverter.twosComplement(hexstr, 16); } res = (isneg ? '-' : '') + NumberBaseConverter.convertBase(hexstr, 16, 10); return res; }, }; // some commonly used Java types in debugger-compatible format const JTYPES = { byte: {name:'int',signature:'B'}, short: {name:'short',signature:'S'}, int: {name:'int',signature:'I'}, long: {name:'long',signature:'J'}, float: {name:'float',signature:'F'}, double: {name:'double',signature:'D'}, char: {name:'char',signature:'C'}, boolean: {name:'boolean',signature:'Z'}, null: {name:'null',signature:'Lnull;'}, // null has no type really, but we need something for literals String: {name:'String',signature:'Ljava/lang/String;'}, Object: {name:'Object',signature:'Ljava/lang/Object;'}, isArray(t) { return t.signature[0]==='[' }, isObject(t) { return t.signature[0]==='L' }, isReference(t) { return /^[L[]/.test(t.signature) }, isPrimitive(t) { return !JTYPES.isReference(t.signature) }, isInteger(t) { return /^[BIJS]$/.test(t.signature) }, } function ensure_path_end_slash(p) { return p + (/[\\/]$/.test(p) ? '' : path.sep); } 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 = ''; // the filepathname of the built apk this.apk_fpn = ''; // the apk file content this._apk_file_data = null; // the file info, hash and manifest data of the apk this.apk_file_info = {}; // hashmap of packages we found in the source tree this.src_packages = {}; // the device we are debugging this._device = null; // expandable primitives this._expandable_prims = false; // true if the app is resumed, false if stopped (exception, breakpoint, etc) this._running = false; // a promise to wait on for the stack variables to evaluate this._locals_done = null; // the fifo queue of evaluations (watches, hover, etc) 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; // hashmap of variables and frames this._variableHandles = {}; this._frameBaseId = 0x00010000; // high, so we don't clash with thread id's this._nextObjVarRef = 0x10000000; // high, so we don't clash with thread or frame id's // flag to distinguish unexpected disconnection events (initiated from the device) vs user-terminated requests this._isDisconnecting = false; // this debugger uses one-based lines and columns this.setDebuggerLinesStartAt1(true); this.setDebuggerColumnsStartAt1(true); } /** * The 'initialize' request is the first request called by the frontend * to interrogate the features the debug adapter provides. */ initializeRequest(response/*: DebugProtocol.InitializeResponse*/, args/*: DebugProtocol.InitializeRequestArguments*/) { // This debug adapter implements the configurationDoneRequest. response.body.supportsConfigurationDoneRequest = true; this.sendResponse(response); } LOG(msg) { D(msg); this.sendEvent(new OutputEvent(msg)); } WARN(msg) { D(msg = 'Warning: '+msg); this.sendEvent(new OutputEvent(msg)); } failRequest(msg, response) { // yeah, it can happen sometimes... this.WARN(msg); if (response) { response.success = false; this.sendResponse(response); } } launchRequest(response/*: DebugProtocol.LaunchResponse*/, args/*: LaunchRequestArguments*/) { try { D('Launching: ' + JSON.stringify(args)); } catch(ex) {} // 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; // configure the ADB port - if it's undefined, it will set the default value. // if it's not a valid port number, any connection request should neatly fail. ws_proxy.setADBPort(args.adbPort); try { // start by scanning the source folder for stuff we need to know about (packages, manifest, etc) this.src_packages = this.scanSourceSync(this.app_src_root); // warn if we couldn't find any packages (-> no source -> cannot debug anything) if (isEmptyObject(this.src_packages.packages)) 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.sendEvent(new TerminatedEvent(false)); return; } var fail_launch = (msg) => $.Deferred().rejectWith(this, [new Error(msg)]); this.LOG('Checking build') this.getAPKFileInfo() .then(apk_file_info => { this.apk_file_info = apk_file_info; // check if any source file was modified after the apk if (this.src_packages.last_src_modified >= this.apk_file_info.app_modified) { switch (args.staleBuild) { case 'ignore': break; case 'stop': return fail_launch('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; } } // 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 var launchActivity = args.launchActivity; if (!launchActivity) if (!(launchActivity = this.apk_file_info.launcher)) return fail_launch('No valid launch activity found in AndroidManifest.xml or launch.json'); return this.findSuitableDevice(args.targetDevice); }) .then(device => { this._device = device; this._device.adbclient = new ADBClient(this._device.serial); // we've got our device - 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.package}|grep -o -e '/.*' || echo '/system/bin/sha1sum')`; return this._device.adbclient.shell_cmd({command: query_app_hash}); }) .then(sha1sum_output => { 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 if (installed_hash === this.apk_file_info.content_hash) { this.LOG('Current build already installed'); return; } return this.copyAndInstallAPK(); }) .then(() => { // when we reach here, the app should be installed and ready to be launched // - before we continue, splunk the apk file data because node *still* hangs when evaluating large arrays this._apk_file_data = null; // start the launch var launchActivity = args.launchActivity; if (!launchActivity) if (!(launchActivity = this.apk_file_info.launcher)) return fail_launch('No valid launch activity found in AndroidManifest.xml or launch.json'); var build = { pkgname:this.apk_file_info.package, packages:Object.assign({}, this.src_packages.packages), launchActivity: launchActivity, }; this.LOG(`Launching ${build.pkgname+'/'+launchActivity} on device ${this._device.serial}`); return this.dbgr.startDebugSession(build, this._device.serial, launchActivity); }) .then(() => { // if we get this far, the debugger is connected and waiting for the resume command // - set up some events this.dbgr.on('bpstatechange', this, this.onBreakpointStateChange) .on('bphit', this, this.onBreakpointHit) .on('step', this, this.onStep) .on('disconnect', this, this.onDebuggerDisconnect); this.waitForConfigurationDone = $.Deferred(); // - tell the client we're initialised and ready for breakpoint info, etc this.sendEvent(new InitializedEvent()); return this.waitForConfigurationDone; }) .then(() => { // config is done - we're all set and ready to go! D('Continuing app start'); this.continueRequest(response, {is_start:true}); }) .fail(e => { // exceptions use message, adbclient uses msg this.LOG('Launch failed: '+(e.message||e.msg||'No additional information is available')); // more info for adb connect errors if (/^ADB server is not running/.test(e.msg)) { this.LOG('Make sure the Android SDK tools are installed and run:'); this.LOG(' adb start-server'); this.LOG('If you are running ADB on a non-default port, also make sure the adbPort value in your launch.json is correct.'); } // tell the client we're done this.sendEvent(new TerminatedEvent(false)); }); } copyAndInstallAPK() { // copy the file to the device this.LOG('Deploying current build...'); return this._device.adbclient.push_file({ filepathname:'/data/local/tmp/debug.apk', filedata:this._apk_file_data, filemtime:new Date().getTime(), }) .then(() => { // send the install command this.LOG('Installing...'); return this._device.adbclient.shell_cmd({ command:'pm install -r /data/local/tmp/debug.apk', untilclosed:true, }) }) .then((stdout) => { // failures: // pkg: x-y-z.apk // Failure [INSTALL_FAILED_OLDER_SDK] var m = stdout.match(/Failure\s+\[([^\]]+)\]/g); if (m) { return $.Deferred().rejectWith(this, [new Error('Installation failed. ' + m[0])]); } }) } getAPKFileInfo() { var done = $.Deferred(); done.result = { fpn:this.apk_fpn, app_modified:0, content_hash:'', manifest:'', package:'', activities:[], launcher:'' }; // read the APK fs.readFile(this.apk_fpn, (err,apk_file_data) => { if (err) return done.rejectWith(this, [new Error('APK read error. ' + err.message)]); // debugging is painful when the APK file content is large, so keep the data in a separate field so node // doesn't have to evaluate it when we're looking at the apk info this._apk_file_data = apk_file_data; // save the last modification time of the app done.result.app_modified = fs.statSync(done.result.fpn).mtime.getTime(); // create a SHA-1 hash as a simple way to see if we need to install/update the app const h = crypto.createHash('SHA1'); h.update(apk_file_data); done.result.content_hash = h.digest('hex'); // read the manifest fs.readFile(path.join(this.app_src_root,'AndroidManifest.xml'), 'utf8', (err,manifest) => { if (err) return done.rejectWith(this, [new Error('Manifest read error. ' + err.message)]); done.result.manifest = manifest; try { const doc = new dom().parseFromString(manifest); // extract the package name from the manifest const pkg_xpath = '/manifest/@package'; done.result.package = xpath.select1(pkg_xpath, doc).value; const android_select = xpath.useNamespaces({"android": "http://schemas.android.com/apk/res/android"}); // extract a list of all the (named) activities declared in the manifest const activity_xpath='/manifest/application/activity/@android:name'; var nodes = android_select(activity_xpath, doc); nodes && (done.result.activities = nodes.map(n => n.value)); // extract the default launcher activity const launcher_xpath='/manifest/application/activity[intent-filter/action[@android:name="android.intent.action.MAIN"] and intent-filter/category[@android:name="android.intent.category.LAUNCHER"]]/@android:name'; var nodes = android_select(launcher_xpath, doc); // should we warn if there's more than one? if (nodes && nodes.length >= 1) done.result.launcher = nodes[0].value } catch(err) { return done.rejectWith(this, [new Error('Manifest parse failed. ' + err.message)]); } done.resolveWith(this, [done.result]); }); }); return done; } scanSourceSync(app_root) { try { // scan known app folders looking for file changes and package folders var p, paths = fs.readdirSync(app_root,'utf8'), done=[]; var src_packages = { last_src_modified: 0, packages: {}, }; while (paths.length) { p = paths.shift(); // just in case someone has some crazy circular links going on if (done.indexOf(p)>=0) continue; done.push(p); var subfiles = [], stat, fpn = path.join(app_root,p); try { stat = fs.statSync(fpn); src_packages.last_src_modified = Math.max(src_packages.last_src_modified, stat.mtime.getTime()); if (!stat.isDirectory()) continue; subfiles = fs.readdirSync(fpn, 'utf8'); } catch (err) { continue } // ignore folders not starting with a known top-level Android folder if (!/^(assets|res|src|main|java)([\\/]|$)/.test(p)) continue; // is this a package folder var pkgmatch = p.match(/^(src|main|java)[\\/](.+)/); if (pkgmatch && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(pkgmatch[2].split(/[\\/]/).pop())) { // looks good - add it to the list const src_folder = pkgmatch[1]; // src, main or java const pkgname = pkgmatch[2].replace(/[\\/]/g,'.'); src_packages.packages[pkgname] = { package: pkgname, package_path: fpn, srcroot: path.join(app_root,src_folder), } } // add the subfiles to the list to process paths = subfiles.map(sf => path.join(p,sf)).concat(paths); } return src_packages; } catch(err) { throw new Error('Source path error: ' + err.message); } } findSuitableDevice(target_deviceid) { this.LOG('Searching for devices...'); return this.dbgr.list_devices() .then(devices => { this.LOG(`Found ${devices.length} device${devices.length===1?'':'s'}`); var reject; if (devices.length === 0) { reject = 'No devices are connected'; } else if (target_deviceid) { // check (only one of) the requested device is present var 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}`)); } return $.Deferred().rejectWith(this, [new Error(reject)]); }) } configurationDoneRequest(response, args) { this.waitForConfigurationDone.resolve(); 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)); } } disconnectRequest(response, args) { D('disconnectRequest'); this._isDisconnecting = true; // if we're connected, ask ADB to terminate the app if (this.dbgr.status() === 'connected') this.dbgr.forcestop(); return this.dbgr.disconnect(response) .then((state, response) => { if (/^connect/.test(state)) this.LOG(`Debugger disconnected`); this.sendResponse(response); //this.sendEvent(new ExitedEvent(0)); }) } onBreakpointStateChange(e) { e.breakpoints.forEach(javabp => { // if there's no associated vsbp we're deleting it, so just ignore the update if (!javabp.vsbp) return; var verified = !!javabp.state.match(/set|enabled/); javabp.vsbp.verified = verified; this.sendEvent(new BreakpointEvent('updated', javabp.vsbp)); }); } onBreakpointHit(e) { D('Breakpoint hit: ' + JSON.stringify(e.stoppedlocation)); this._running = false; var tid = parseInt(e.stoppedlocation.threadid,16); this.sendEvent(new StoppedEvent("breakpoint", tid)); } markAllThreadsStopped(reason, exclude) { this.dbgr.allthreads(reason) .then(threads => { if (Array.isArray(exclude)) threads = threads.filter(t => !exclude.includes(t)); threads.forEach(t => this.sendEvent(new StoppedEvent(reason, parseInt(t,16)))); }); } /** * 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 */ setBreakPointsRequest(response/*: DebugProtocol.SetBreakpointsResponse*/, args/*: DebugProtocol.SetBreakpointsArguments*/) { var srcfpn = args.source && args.source.path; var clientLines = args.lines; D('setBreakPointsRequest: ' + srcfpn); // the file must lie inside one of the source packages we found (and it must be have a .java extension) var srcfolder = path.dirname(srcfpn); var pkginfo; for (var pkg in this.src_packages.packages) { if ((pkginfo = this.src_packages.packages[pkg]).package_path === srcfolder) break; pkginfo = null; } if (!pkginfo || !/\.java$/.test(srcfpn)) { // source file is not a java file or is outside of the known source packages // just send back a list of unverified breakpoints response.body = { breakpoints: args.lines.map(l => { var bp = new Breakpoint(false,l); bp.id = ++this._breakpointId; return bp; }) }; this.sendResponse(response); 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 var relative_fpn = srcfpn.slice(pkginfo.srcroot.length); // delete any existing breakpoints not in the list this.dbgr.clearbreakpoints(javabp => { var remove = javabp.srcfpn===relative_fpn && !clientLines.includes(javabp.linenum); if (remove) javabp.vsbp = null; return remove; }); // return the list of new and existing breakpoints var breakpoints = clientLines.map((line,idx) => { var dbgline = this.convertClientLineToDebugger(line); var javabp = this.dbgr.setbreakpoint(relative_fpn, dbgline); if (!javabp.vsbp) { // state is one of: set,notloaded,enabled,removed var 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; javabp.vsbp = bp; } javabp.vsbp.order = idx; return javabp.vsbp; }); // send back the actual breakpoint positions response.body = { breakpoints: breakpoints }; this.sendResponse(response); } threadsRequest(response/*: DebugProtocol.ThreadsResponse*/) { this.dbgr.allthreads(response) .then((threads, response) => { // convert the (hex) thread strings into real numbers var tids = threads.map(t => parseInt(t,16)); response.body = { threads: tids.map(tid => new Thread(tid, `Thread (id:${tid})`)) }; this.sendResponse(response); }) .fail(e => { response.success = false; this.sendResponse(response); }); } /** * Returns a stack trace for the given threadId */ stackTraceRequest(response/*: DebugProtocol.StackTraceResponse*/, args/*: DebugProtocol.StackTraceArguments*/) { // debugger threadid's are a padded 64bit hex number var threadid = ('000000000000000' + args.threadId.toString(16)).slice(-16); // retrieve the (stack) frames from the debugger this.dbgr.getframes(threadid, {response:response, args:args}) .then((frames, x) => { // first ensure that the line-tables for all the methods are loaded var defs = frames.map(f => this.dbgr._ensuremethodlines(f.method)); defs.unshift(frames,x); return $.when.apply($,defs); }) .then((frames, x) => { const startFrame = typeof x.args.startFrame === 'number' ? x.args.startFrame : 0; const maxLevels = typeof x.args.levels === 'number' ? x.args.levels : frames.length-startFrame; const endFrame = Math.min(startFrame + maxLevels, frames.length); var stack = [], totalFrames = frames.length, highest_known_source=0; for (var i= startFrame; i < endFrame; i++) { // the stack_frame_id must be unique across all threads const stack_frame_id = (x.args.threadId * this._frameBaseId) + i; this._variableHandles[stack_frame_id] = { varref: stack_frame_id, frame: frames[i], threadId:x.args.threadId }; const name = `${frames[i].method.owningclass.name}.${frames[i].method.name}`; const pkginfo = this.src_packages.packages[frames[i].method.owningclass.type.package]; const sourcefile = frames[i].method.owningclass.src.sourcefile; const srcloc = this.dbgr.line_idx_to_source_location(frames[i].method, frames[i].location.idx); if (!srcloc) { totalFrames--; continue; // ignore frames which have no location (they're probably synthetic) } const linenum = srcloc && this.convertDebuggerLineToClient(srcloc.linenum); const src = sourcefile && new Source(sourcefile, (pkginfo && path.join(pkginfo.package_path,sourcefile))||'', pkginfo ? 0 : 1); pkginfo && (highest_known_source=i); stack.push(new StackFrame(stack_frame_id, name, src, linenum, 0)); } // FIX: trim the stack to exclude anything above the known sources - otherwise an error occurs in the editor when the user tries to view it stack = stack.slice(0,highest_known_source+1); totalFrames = stack.length; // return the frames response.body = { stackFrames: stack, totalFrames: totalFrames, }; this.sendResponse(response); }); } scopesRequest(response/*: DebugProtocol.ScopesResponse*/, args/*: DebugProtocol.ScopesArguments*/) { response.body = { scopes: [new Scope("Local", args.frameId, false)] }; this.sendResponse(response); } sourceRequest(response/*: DebugProtocol.SourceResponse*/, args/*: DebugProtocol.SourceArguments*/) { response.body = { content:'// The source for this class is unavailable.' } this.sendResponse(response); } /** * Converts locals (or other vars) in debugger format into Variable objects used by VSCode */ _locals_to_variables(vars) { return vars.map(v => { var varref = 0, objvalue, typename = v.type.package ? `${v.type.package}.${v.type.typename}` : v.type.typename; switch(true) { case v.hasnullvalue && JTYPES.isReference(v.type): // null object or array type objvalue = 'null'; break; case v.type.signature === JTYPES.Object.signature: // Object doesn't really have anything worth seeing, so just treat it as unexpandable objvalue = v.type.typename; break; case v.type.signature === JTYPES.String.signature: objvalue = JSON.stringify(v.string); if (v.biglen) { // since this is a big string - make it viewable on expand varref = ++this._nextObjVarRef; this._variableHandles[varref] = {varref:varref, bigstring:v}; objvalue = `String (length:${v.biglen})`; } else if (this._expandable_prims) { // as a courtesy, allow strings to be expanded to see their length varref = ++this._nextObjVarRef; this._variableHandles[varref] = {varref:varref, signature:v.type.signature, primitive:true, value:v.string.length}; } break; case JTYPES.isArray(v.type): // non-null array type - if it's not zero-length add another variable reference so the user can expand if (v.arraylen) { varref = ++this._nextObjVarRef; this._variableHandles[varref] = { varref:varref, arrvar:v, range:[0,v.arraylen] }; } objvalue = v.type.typename.replace(/]$/, v.arraylen+']'); // insert len as the final array bound break; case JTYPES.isObject(v.type): // non-null object instance - add another variable reference so the user can expand varref = ++this._nextObjVarRef; this._variableHandles[varref] = {varref:varref, objvar:v}; objvalue = v.type.typename; break; case v.type.signature === 'C': const cmap = {'\f':'f','\r':'r','\n':'n','\t':'t','\v':'v','\'':'\'','\\':'\\'}, cc = v.value.charCodeAt(0); if (cmap[v.value]) { objvalue = `'\\${cmap[v.value]}'`; } else if (cc < 32) { objvalue = cc ? `'\\u${('000'+cc.toString(16)).slice(-4)}'` : "'\\0'"; } else objvalue = `'${v.value}'`; break; case v.type.signature === 'J': // because JS cannot handle 64bit ints, we need a bit of extra work var v64hex = v.value.replace(/[^0-9a-fA-F]/g,''); objvalue = NumberBaseConverter.hexToDec(v64hex, true); break; default: // other primitives: int, boolean, etc objvalue = v.value.toString(); break; } // as a courtesy, allow integer and character values to be expanded to show the value in alternate bases if (this._expandable_prims && /^[IJBSC]$/.test(v.type.signature)) { varref = ++this._nextObjVarRef; this._variableHandles[varref] = {varref:varref, signature:v.type.signature, primitive:true, value:v.value}; } return { name: v.name, type: typename, value: objvalue, variablesReference: varref, } }); } variablesRequest(response/*: DebugProtocol.VariablesResponse*/, args/*: DebugProtocol.VariablesArguments*/) { const return_mapped_vars = (vars, response) => { response.body = { variables: this._locals_to_variables(vars.filter(v => v.valid)) }; this.sendResponse(response); } var varinfo = this._variableHandles[args.variablesReference]; if (!varinfo) { return_mapped_vars([], response); } else if (varinfo.cached) { return_mapped_vars(varinfo.cached, response); } else if (varinfo.objvar) { // object fields request this.dbgr.getsupertype(varinfo.objvar, {varinfo:varinfo, response:response}) .then((supertype, x) => { x.supertype = supertype; return this.dbgr.getfieldvalues(x.varinfo.objvar, x); }) .then((fields, x) => { // ignore supertypes of Object x.supertype && x.supertype.signature!=='Ljava/lang/Object;' && fields.unshift({ vtype:'super', name:'super', hasnullvalue:false, type: x.supertype, value: x.varinfo.objvar.value, valid:true, }); x.varinfo.cached = fields; return_mapped_vars(fields, x.response); }); } else if (varinfo.arrvar) { // array elements request var range = varinfo.range, count = range[1] - range[0]; // should always have a +ve count, but just in case... if (count <= 0) return return_mapped_vars([], response); // add some hysteresis if (count > 110) { // create subranges in the sub-power of 10 var subrangelen = Math.max(Math.pow(10, (Math.log10(count)|0)-1),100), variables = []; for (var i=range[0],varref,v; i < range[1]; i+= subrangelen) { varref = ++this._nextObjVarRef; v = this._variableHandles[varref] = { varref:varref, arrvar:varinfo.arrvar, range:[i, Math.min(i+subrangelen, range[1])] }; variables.push({name:`[${v.range[0]}..${v.range[1]-1}]`,type:'',value:'',variablesReference:varref}); } response.body = { variables: variables }; this.sendResponse(response); return; } // get the elements for the specified range this.dbgr.getarrayvalues(varinfo.arrvar, range[0], count, response) .then((elements, response) => { varinfo.cached = elements; return_mapped_vars(elements, response); }); } else if (varinfo.bigstring) { this.dbgr.getstringchars(varinfo.bigstring.value, response) .then((s,response) => { return_mapped_vars([{name:'',hasnullvalue:false,string:s,type:JTYPES.String,valid:true}], response); }); } else if (varinfo.primitive) { // convert the primitive value into alternate formats var variables = [], bits = {J:64,I:32,S:16,B:8}[varinfo.signature]; const pad = (u,base,len) => ('0000000000000000000000000000000'+u.toString(base)).slice(-len); switch(varinfo.signature) { case 'Ljava/lang/String;': variables.push({name:'',type:'',value:varinfo.value.toString(),variablesReference:0}); break; case 'C': variables.push({name:'',type:'',value:varinfo.value.charCodeAt(0).toString(),variablesReference:0}); break; case 'J': // because JS cannot handle 64bit ints, we need a bit of extra work var v64hex = varinfo.value.replace(/[^0-9a-fA-F]/g,''); const s4 = { hi:parseInt(v64hex.slice(0,8),16), lo:parseInt(v64hex.slice(-8),16) }; variables.push( {name:'',type:'',value:pad(s4.hi,2,32)+pad(s4.lo,2,32),variablesReference:0} ,{name:'',type:'',value:NumberBaseConverter.hexToDec(v64hex,false),variablesReference:0} ,{name:'',type:'',value:pad(s4.hi,16,8)+pad(s4.lo,16,8),variablesReference:0} ); break; default:// integer/short/byte value const u = varinfo.value >>> 0; variables.push( {name:'',type:'',value:pad(u,2,bits),variablesReference:0} ,{name:'',type:'',value:u.toString(10),variablesReference:0} ,{name:'',type:'',value:pad(u,16,bits/4),variablesReference:0} ); break; } response.body = { variables: variables }; this.sendResponse(response); } else { // frame locals request this.dbgr.getlocals(varinfo.frame.threadid, varinfo.frame, response) .then((locals, response) => { varinfo.cached = locals; return_mapped_vars(locals, response); if (this._locals_done) { this._locals_done.resolveWith(this, [locals]); this._locals_done = null; }; }); } } continueRequest(response/*: DebugProtocol.ContinueResponse*/, args/*: DebugProtocol.ContinueArguments*/) { D('Continue'); this._variableHandles = {}; // sometimes, the device is so quick that a breakpoint is hit // before we've completed the resume promise chain. // so tell the client that we've resumed now and just send a StoppedEvent // if it ends up failing this._running = true; this._locals_done = $.Deferred(); this.dbgr.resume() .then(() => { if (args.is_start) this.LOG(`App started`); }) .fail(() => { if (!response) this.sendEvent(new StoppedEvent('Continue failed')); this.failRequest('Resume command failed', response); response = null; }); response && this.sendResponse(response) && D('Sent continue response'); response = null; } /** * Called by the debugger after a step operation has completed */ onStep(e) { D('step hit: ' + JSON.stringify(e.stoppedlocation)); this._running = false; this.sendEvent(new StoppedEvent("step", parseInt(e.stoppedlocation.threadid,16))); } /** * Called by the user to start a step operation */ doStep(which, response, args) { D('step '+which); this._variableHandles = {}; var threadid = ('000000000000000' + args.threadId.toString(16)).slice(-16); this.dbgr.step(which, threadid) .then(() => { this._running = true; this._locals_done = $.Deferred(); this.sendResponse(response); }); } stepInRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.StepInArguments*/) { this.doStep('in', response, args); } nextRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.NextArguments*/) { this.doStep('over', response, args); } stepOutRequest(response/*: DebugProtocol.NextResponse*/, args/*: DebugProtocol.StepOutArguments*/) { this.doStep('out', response, args); } /** * Called by VSCode to perform watch, console and hover evaluations */ evaluateRequest(response/*: DebugProtocol.EvaluateResponse*/, args/*: DebugProtocol.EvaluateArguments*/) { // 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 if (this._running) { response.body = { result:'(running)', variablesReference:0 }; this.sendResponse(response); return; } this._evals_queue.push([response,args]); if (this._evals_queue.length > 1) return; if (this._locals_done) { // wait for the promise to be resolved (after the locals have been retrieved) this._locals_done.then(() => { // start the evaluations this.doNextEvaluateRequest(); }); return; } // we reach here if the program is paused, all the queued evaluations are done and a new evaluation is requested this.doNextEvaluateRequest(); } sendResponseAndDoNext(response, value, varref) { response.body = { result:value, variablesReference:varref|0 }; this.sendResponse(response); this._evals_queue.shift(); this.doNextEvaluateRequest(); } doNextEvaluateRequest() { if (!this._evals_queue.length) return; this.doEvaluateRequest.apply(this, this._evals_queue[0]); } doEvaluateRequest(response, args) { // just in case the user starts the app running again, before we've evaluated everything in the queue if (this._running) { return this.sendResponseAndDoNext(response, '(running)'); } var parse_array_or_fncall = function(e) { var arg, res = {arr:[], call:null}; // pre-call array indexes while (e.expr[0] === '[') { e.expr = e.expr.slice(1).trim(); if ((arg = parse_expression(e)) === null) return null; res.arr.push(arg); if (e.expr[0] !== ']') return null; e.expr = e.expr.slice(1).trim(); } if (res.arr.length) return res; // method call if (e.expr[0] === '(') { res.call = []; e.expr = e.expr.slice(1).trim(); if (e.expr[0] !== ')') { for (;;) { if ((arg = parse_expression(e)) === null) return null; res.call.push(arg); if (e.expr[0] === ')') break; if (e.expr[0] !== ',') return null; e.expr = e.expr.slice(1).trim(); } } e.expr = e.expr.slice(1).trim(); // post-call array indexes while (e.expr[0] === '[') { e.expr = e.expr.slice(1).trim(); if ((arg = parse_expression(e)) === null) return null; res.arr.push(arg); if (e.expr[0] !== ']') return null; e.expr = e.expr.slice(1).trim(); } } return res; } var parse_expression = function(e) { var root_term = e.expr.match(/^(?:(true(?![\w$]))|(false(?![\w$]))|(null(?![\w$]))|([a-zA-Z_$][a-zA-Z0-9_$]*)|(\d+(?:\.\d+)?)|('[^\\']')|('\\[frntv0]')|('\\u[0-9a-fA-F]{4}')|("[^"]*"))/); if (!root_term) return null; var res = { root_term: root_term[0], root_term_type: ['boolean','boolean','null','ident','number','char','echar','uchar','string'][[1,2,3,4,5,6,7,8,9].find(x => root_term[x])-1], array_or_fncall: null, members:[], } e.expr = e.expr.slice(res.root_term.length).trim(); if ((res.array_or_fncall = parse_array_or_fncall(e)) === null) return null; // the root term is not allowed to be a method call if (res.array_or_fncall.call) return null; while (e.expr[0] === '.') { // member expression e.expr = e.expr.slice(1).trim(); var m, member_name = e.expr.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/); if (!member_name) return null; res.members.push(m = {member:member_name[0], array_or_fncall:null}) e.expr = e.expr.slice(m.member.length).trim(); if ((m.array_or_fncall = parse_array_or_fncall(e)) === null) return null; } return res; } const descape_char = (c) => { if (c.length===2) { // backslash escape var x = {'f':'\f','r':'\r','n':'\n','t':'\t',v:'\v'}[c[1]]; return x || (c[1]==='0'?String.fromCharCode(0):c[1]); } // unicode escape return String.fromCharCode(parseInt(c.slice(2,6),16)); } var reject_evaluation = (msg) => $.Deferred().rejectWith(this, [new Error(msg)]); var evaluate_number = (n) => { const numtype = /\./.test(n) ? JTYPES.double : JTYPES.int; const iszero = /^0+(\.0*)?$/.test(n); return { vtype:'literal',name:'',hasnullvalue:iszero,type:numtype,value:n,valid:true }; } var evaluate_expression = (expr) => { var q = $.Deferred(), local; switch(expr.root_term_type) { case 'boolean': local = { vtype:'literal',name:'',hasnullvalue:false,type:JTYPES.boolean,value:expr.root_term,valid:true }; break; case 'null': const nullvalue = '0000000000000000'; // null reference value local = { vtype:'literal',name:'',hasnullvalue:true,type:JTYPES.null,value:nullvalue,valid:true }; break; case 'ident': var v = this._variableHandles[args.frameId]; if (v && v.frame && v.cached) local = v.cached.find(l => l.name === expr.root_term); break; case 'number': local = evaluate_number(expr.root_term); break; case 'char': local = expr.root_term[1]; // fall-through case 'echar': case 'uchar': !local && (local = descape_char(expr.root_term.slice(1,-1))); // fall-through local = { vtype:'literal',name:'',hasnullvalue:false,type:JTYPES.char,value:local,valid:true }; break; case 'string': const raw = expr.root_term.slice(1,-1).replace(/\\u[0-9a-fA-F]{4}|\\./,descape_char); // we must get the runtime to create string instances q = this.dbgr.createstring(raw); local = {valid:true}; // make sure we don't fail the evaluation break; } if (!local || !local.valid) return reject_evaluation('not available'); // we've got the root term variable - work out the rest q = expr.array_or_fncall.arr.reduce((q,index_expr) => { return q.then(function(index_expr,local) { return evaluate_array_element.call(this,index_expr,local) }.bind(this,index_expr)); }, q); q = expr.members.reduce((q,m) => { return q.then(function(m,local) { return evaluate_member.call(this,m,local) }.bind(this,m)); }, q); // if it's a string literal, we are already waiting for the runtime to create the string // - otherwise, start the evalaution... if (expr.root_term_type !== 'string') q.resolveWith(this,[local]); return q; } var evaluate_array_element = (index_expr, arr_local) => { if (arr_local.type.signature[0] !== '[') return reject_evaluation('TypeError: value is not an array'); if (arr_local.hasnullvalue) return reject_evaluation('NullPointerException'); return evaluate_expression(index_expr) .then(function(arr_local, idx_local) { if (!JTYPES.isInteger(idx_local.type)) return reject_evaluation('TypeError: array index is not an integer value'); var idx = parseInt(idx_local.value,10); if (idx < 0 || idx >= arr_local.arraylen) return reject_evaluation('BoundsError: array index out of bounds'); return this.dbgr.getarrayvalues(arr_local, idx, 1) }.bind(this,arr_local)) .then(els => els[0]) } var evaluate_methodcall = (m, obj_local) => { return reject_evaluation('Error: method calls are not supported'); } var evaluate_member = (m, obj_local) => { if (!JTYPES.isReference(obj_local.type)) return reject_evaluation('TypeError: value is not a reference type'); if (obj_local.hasnullvalue) return reject_evaluation('NullPointerException'); if (m.array_or_fncall.call) return evaluate_methodcall(m, obj_local); // length is a 'fake' field of arrays, so special-case it if (JTYPES.isArray(obj_local.type) && m.member==='length') return evaluate_number(obj_local.arraylen); return this.dbgr.getfieldvalues(obj_local, m) .then((fields,m) => { var field = fields.find(f => f.name === m.member); if (!field) return reject_evaluation('no such field: '+m.member); if (m.array_or_fncall.arr.length) { var q = $.Deferred(); m.array_or_fncall.arr.reduce((q,index_expr) => { return q.then(function(index_expr,local) { return evaluate_array_element(index_expr,local) }.bind(this,index_expr)); }, q); return q.resolveWith(this, [field]); } return field; }) } D('evaluate: ' + args.expression); var e = { expr:args.expression }; var parsed_expression = parse_expression(e); // if there's anything left, it's an error if (parsed_expression && !e.expr) { // the expression is well-formed - start the (asynchronous) evaluation evaluate_expression(parsed_expression) .then(function(response,local) { var v = this._locals_to_variables([local])[0]; this.sendResponseAndDoNext(response, v.value, v.variablesReference); }.bind(this,response)) .fail(function(response,reason) { this.sendResponseAndDoNext(response, reason.message); }.bind(this,response)) return; } // the expression is not well-formed this.sendResponseAndDoNext(response, 'not available'); } } DebugSession.run(AndroidDebugSession);