mirror of
https://github.com/adelphes/android-dev-ext.git
synced 2025-12-23 01:48:18 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92f247ef6 | ||
|
|
133b7061b2 | ||
|
|
7e958620a8 | ||
|
|
989de8254a | ||
|
|
8f2f7d2fd4 | ||
|
|
99544b527d | ||
|
|
614fcbd2ba | ||
|
|
b04e4328a6 | ||
|
|
1a00cdb291 | ||
|
|
d1fd889433 | ||
|
|
a7e4cac6df | ||
|
|
c6df24ab95 | ||
|
|
23184ea4c2 | ||
|
|
b7ba47b811 | ||
|
|
033f5c80ab | ||
|
|
0cbb56ca9b | ||
|
|
684dd39181 | ||
|
|
52ab704acd | ||
|
|
45e2dc2fe1 | ||
|
|
30ed5dea3b | ||
|
|
0eb44130a6 | ||
|
|
d1e7c86092 | ||
|
|
690f9dc23a | ||
|
|
27ecd41b68 | ||
|
|
756a1cea29 | ||
|
|
fc2ce97a23 | ||
|
|
de8abc62bc | ||
|
|
8cc31476b3 |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# Change Log
|
||||
|
||||
### version 0.8.0
|
||||
* Try to extract Android manifest directly from APK
|
||||
* Added `manifestFile` launch configuration property
|
||||
* Allow `pm install` arguments to be customised as a launch configuration property
|
||||
* Document `launchActivity` launch configuration property
|
||||
* Fix critical security advisory https://www.npmjs.com/advisories/1118
|
||||
|
||||
### version 0.7.1
|
||||
* Added the [Buy Me A Coffee](https://www.buymeacoffee.com/adelphes) link to the README
|
||||
|
||||
### version 0.7.0
|
||||
* Fix logcat not displaying
|
||||
* Fix breakpoints not triggering on Windows
|
||||
* Added kotlin folder to list of known source locations
|
||||
* Upgraded dependencies to resolve a number of security vulnerabilites
|
||||
* Updated README with info about prelaunch build task
|
||||
* Added MIT license file
|
||||
|
||||
### version 0.6.2
|
||||
* Fix broken logcat command due to missing dependency
|
||||
|
||||
### version 0.6.1
|
||||
* Regenerate package-lock.json to remove event-stream vulnerability - https://github.com/dominictarr/event-stream/issues/116
|
||||
|
||||
### version 0.6.0
|
||||
* Fix issue with breakpoints not enabling correctly
|
||||
* Fix issue with JDWP failure on breakpoint hit
|
||||
* Added support for diagnostic logs using trace configuration option
|
||||
* Updated default apkFile path to match current releases of Android Studio
|
||||
* Updated package dependencies
|
||||
|
||||
### version 0.5.0
|
||||
* Debugger support for Kotlin source files
|
||||
* Exception UI
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Dave Holoway
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
60
README.md
60
README.md
@@ -20,7 +20,7 @@ You must have [Android SDK Platform Tools](https://developer.android.com/studio/
|
||||
* This is a preview version so expect the unexpected. Please log any issues you find on [GitHub](https://github.com/adelphes/android-dev-ext/issues).
|
||||
* This extension **will not build your app**.
|
||||
If you use gradle (or Android Studio), you can build your app from the command-line using `./gradlew assembleDebug`.
|
||||
> You must use gradle or some other build procedure to create your APK. Once built, the extension can deploy and launch your app, allowing you to debug it in the normal way.
|
||||
> You must use gradle or some other build procedure to create your APK. Once built, the extension can deploy and launch your app, allowing you to debug it in the normal way. See the section below on how to configure a VSCode task to automatically build your app before launching a debug session.
|
||||
* Some debugger options are yet to be implemented. You cannot set conditional breakpoints and watch expressions must be simple variables.
|
||||
* If you require a must-have feature that isn't there yet, let us know on [GitHub](https://github.com/adelphes/android-dev-ext/issues).
|
||||
* This extension does not provide any additional code completion or other editing enhancements.
|
||||
@@ -29,7 +29,7 @@ If you use gradle (or Android Studio), you can build your app from the command-l
|
||||
|
||||
This extension allows you to debug your App by creating a new Android configuration in `launch.json`.
|
||||
The following settings are used to configure the debugger:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
@@ -50,9 +50,65 @@ The following settings are used to configure the debugger:
|
||||
|
||||
// Launch behaviour if source files have been saved after the APK was built. One of: [ ignore warn stop ]. Default: warn
|
||||
"staleBuild": "warn",
|
||||
|
||||
// Fully qualified path to the AndroidManifest.xml file compiled in the APK. Default: appSrcRoot/AndroidManifest.xml
|
||||
"manifestFile": "${workspaceRoot}/app/src/main/AndroidManifest.xml",
|
||||
|
||||
// APK install arguments passed to the Android package manager. Run 'adb shell pm' to show valid arguments. Default: ["-r"]
|
||||
"pmInstallArgs": ["-r"],
|
||||
|
||||
// Manually specify the activity to run when the app is started.
|
||||
"launchActivity": ".MainActivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Building your app automatically
|
||||
|
||||
This extension will not build your App. If you would like to run a build each time a debug session is started, you can add a `preLaunchTask` option to your `launch.json` configuration which invokes a build task.
|
||||
|
||||
#### .vscode/launch.json
|
||||
Add a `preLaunchTask` item to the launch configuration:
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "android",
|
||||
"request": "launch",
|
||||
"name": "App Build & Launch",
|
||||
"preLaunchTask": "run gradle",
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Add a new task to run the build command:
|
||||
#### .vscode/tasks.json
|
||||
```json
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "run gradle",
|
||||
"type": "shell",
|
||||
"command": "${workspaceFolder}/gradlew",
|
||||
"args": ["assembleDebug"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Powered by coffee
|
||||
|
||||
The Android Developer Extension is a completely free, fully open-source project. If you've found the extension useful, you
|
||||
can support it by [buying me a coffee](https://www.buymeacoffee.com/adelphes).
|
||||
|
||||
If you use ApplePay or Google Pay, you can scan the code with your phone camera:
|
||||
|
||||

|
||||
|
||||
Every coffee makes a difference, so thanks for adding your support.
|
||||
|
||||
## Questions / Problems
|
||||
|
||||
|
||||
BIN
images/bmac-code.png
Normal file
BIN
images/bmac-code.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
2753
package-lock.json
generated
Normal file
2753
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
60
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "android-dev-ext",
|
||||
"displayName": "Android",
|
||||
"description": "Android debugging support for VS Code",
|
||||
"version": "0.5.0",
|
||||
"version": "0.8.0",
|
||||
"publisher": "adelphes",
|
||||
"preview": true,
|
||||
"license": "MIT",
|
||||
@@ -43,7 +43,7 @@
|
||||
"debuggers": [
|
||||
{
|
||||
"type": "android",
|
||||
"label": "Android Debug",
|
||||
"label": "Android",
|
||||
"program": "./src/debugMain.js",
|
||||
"runtime": "node",
|
||||
"configurationAttributes": {
|
||||
@@ -62,7 +62,7 @@
|
||||
"apkFile": {
|
||||
"type": "string",
|
||||
"description": "Fully qualified path to the built APK (Android Application Package)",
|
||||
"default": "${workspaceRoot}/app/build/outputs/apk/app-debug.apk"
|
||||
"default": "${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk"
|
||||
},
|
||||
"adbPort": {
|
||||
"type": "integer",
|
||||
@@ -79,11 +79,28 @@
|
||||
"description": "Number of entries to display in call stack views (for locations outside of the project source). 0 shows the entire call stack. Default: 1",
|
||||
"default": 1
|
||||
},
|
||||
"launchActivity": {
|
||||
"type": "string",
|
||||
"description": "Manually specify the activity to run when the app is started.",
|
||||
"default": ""
|
||||
},
|
||||
"logcatPort": {
|
||||
"type": "integer",
|
||||
"description": "Port number to use for the internal logcat websocket link. Changes to this value only apply when the extension is restarted. Default: 7038",
|
||||
"default": 7038
|
||||
},
|
||||
"manifestFile": {
|
||||
"type": "string",
|
||||
"description": "Overrides the default location of AndroidManifest.xml",
|
||||
"default": "${workspaceRoot}/app/src/main/AndroidManifest.xml"
|
||||
},
|
||||
"pmInstallArgs": {
|
||||
"type": "array",
|
||||
"description": "APK install arguments passed to the Android package manager. Run 'adb shell pm' to show valid arguments. Default: [\"-r\"]",
|
||||
"default": [
|
||||
"-r"
|
||||
]
|
||||
},
|
||||
"staleBuild": {
|
||||
"type": "string",
|
||||
"description": "Launch behaviour if source files have been saved after the APK was built. One of: [\"ignore\" \"warn\" \"stop\"]. Default: \"warn\"",
|
||||
@@ -93,6 +110,11 @@
|
||||
"type": "string",
|
||||
"description": "Target Device ID (as indicated by 'adb devices'). Use this to specify which device is used for deployment when multiple devices are connected.",
|
||||
"default": ""
|
||||
},
|
||||
"trace": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true to output debugging logs for diagnostics",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +122,10 @@
|
||||
"initialConfigurations": [
|
||||
{
|
||||
"type": "android",
|
||||
"name": "Android Debug",
|
||||
"name": "Android",
|
||||
"request": "launch",
|
||||
"appSrcRoot": "${workspaceRoot}/app/src/main",
|
||||
"apkFile": "${workspaceRoot}/app/build/outputs/apk/app-debug.apk",
|
||||
"apkFile": "${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk",
|
||||
"adbPort": 5037
|
||||
}
|
||||
],
|
||||
@@ -116,7 +138,7 @@
|
||||
"request": "launch",
|
||||
"name": "${2:Launch App}",
|
||||
"appSrcRoot": "^\"\\${workspaceRoot}/app/src/main\"",
|
||||
"apkFile": "^\"\\${workspaceRoot}/app/build/outputs/apk/app-debug.apk\"",
|
||||
"apkFile": "^\"\\${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk\"",
|
||||
"adbPort": 5037
|
||||
}
|
||||
}
|
||||
@@ -126,23 +148,25 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node ./node_modules/vscode/bin/install",
|
||||
"prepare": "node ./node_modules/vscode/bin/install",
|
||||
"test": "node ./node_modules/vscode/bin/test"
|
||||
},
|
||||
"dependencies": {
|
||||
"vscode-debugprotocol": "^1.20.0",
|
||||
"vscode-debugadapter": "^1.20.0",
|
||||
"long": "^3.2.0",
|
||||
"ws": "^1.1.1",
|
||||
"long": "^4.0.0",
|
||||
"unzipper": "^0.10.4",
|
||||
"uuid": "^3.3.2",
|
||||
"vscode-debugadapter": "^1.32.0",
|
||||
"vscode-debugprotocol": "^1.32.0",
|
||||
"ws": "^7.1.2",
|
||||
"xmldom": "^0.1.27",
|
||||
"xpath": "^0.0.23"
|
||||
"xpath": "^0.0.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^2.0.3",
|
||||
"vscode": "^1.0.0",
|
||||
"mocha": "^2.3.3",
|
||||
"eslint": "^3.6.0",
|
||||
"@types/node": "^6.0.40",
|
||||
"@types/mocha": "^2.2.32"
|
||||
"@types/mocha": "^5.2.5",
|
||||
"@types/node": "^10.12.5",
|
||||
"eslint": "^5.9.0",
|
||||
"mocha": "^5.2.0",
|
||||
"typescript": "^3.1.6",
|
||||
"vscode": "^1.1.26"
|
||||
}
|
||||
}
|
||||
281
src/apkdecoder.js
Normal file
281
src/apkdecoder.js
Normal file
@@ -0,0 +1,281 @@
|
||||
const START_NAMESPACE_SPEC = {
|
||||
hdr: '0x0100',
|
||||
hdrsz: '0x0010',
|
||||
sz: '0x00000018',
|
||||
startline: 4,
|
||||
commentId: 4,
|
||||
nameId: 4,
|
||||
valueId: 4,
|
||||
}
|
||||
const END_NAMESPACE_SPEC = {
|
||||
hdr: '0x0101',
|
||||
hdrsz: '0x0010',
|
||||
sz: '0x00000018',
|
||||
startline: 4,
|
||||
commentId: 4,
|
||||
nameId: 4,
|
||||
valueId: 4,
|
||||
}
|
||||
const BEGIN_NODE_SPEC = {
|
||||
hdr: '0x0102',
|
||||
hdrsz: '0x0010',
|
||||
sz: 4,
|
||||
startline: 4,
|
||||
commentId: 4,
|
||||
namespaceId: 4,
|
||||
nameId: 4,
|
||||
attr: {
|
||||
offset: 2,
|
||||
size: 2,
|
||||
count: 2,
|
||||
},
|
||||
id_attr_offset: 2,
|
||||
cls_attr_offset: 2,
|
||||
style_attr_offset: 2,
|
||||
attributes: [{
|
||||
length: main => main.attr.count,
|
||||
element_spec: {
|
||||
ns: 4,
|
||||
nameId: 4,
|
||||
commentId: 4,
|
||||
sz: '0x0008',
|
||||
zero: '0x00',
|
||||
type: 1,
|
||||
value: 4,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
const END_NODE_SPEC = {
|
||||
hdr: '0x0103',
|
||||
hdrsz: '0x0010',
|
||||
sz: 4,
|
||||
startline: 4,
|
||||
commentId: 4,
|
||||
namespaceId: 4,
|
||||
nameId: 4,
|
||||
}
|
||||
|
||||
function decode_spec_value(o, key, value, buf, idx, main) {
|
||||
let byteLength;
|
||||
switch (true) {
|
||||
case typeof value === 'number': {
|
||||
// raw integer value
|
||||
byteLength = value;
|
||||
o[key] = buf.readIntLE(idx, byteLength);
|
||||
break;
|
||||
}
|
||||
case Array.isArray(value): {
|
||||
// known-length array of values
|
||||
const length = value[0].length(main);
|
||||
byteLength = 0;
|
||||
o[key] = new Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const bytes = decode_spec_value(o[key], i, value[0].element_spec, buf, idx, main);
|
||||
idx += bytes;
|
||||
byteLength += bytes;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case typeof value === 'object': {
|
||||
// named sub-spec
|
||||
o[key] = {};
|
||||
byteLength = decode_spec(buf, value, o[key], o, idx);
|
||||
break
|
||||
}
|
||||
case /^0x[\da-fA-F]/.test(value): {
|
||||
// exact integer value
|
||||
byteLength = (value.length - 2) / 2;
|
||||
o[key] = buf.readUIntLE(idx, byteLength);
|
||||
if (parseInt(value) !== o[key]) {
|
||||
throw new Error(`Bad value. Expected ${value}, got 0x${o[key].toString(16)}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case value === 'length-utf16-null': {
|
||||
// 2-byte length, utf16 chars, null char
|
||||
const string_byte_length = buf.readUInt16LE(idx) * 2; // 1 char = 2 bytes
|
||||
idx += 2;
|
||||
o[key] = buf.slice(idx, idx + string_byte_length).toString('ucs2');
|
||||
idx += string_byte_length;
|
||||
if (buf.readUInt16LE(idx) !== 0) {
|
||||
throw new Error(`Bad value. Nul char expected but not found.`);
|
||||
}
|
||||
byteLength = 2 + string_byte_length + 2;
|
||||
break;
|
||||
}
|
||||
case /^align:\d+$/.test(value): {
|
||||
// used for arbitrary padding to a specified alignment
|
||||
const align = parseInt(value.split(':')[1], 10);
|
||||
byteLength = align - (idx % align);
|
||||
o[key] = buf.slice(idx, idx + byteLength);
|
||||
break;
|
||||
}
|
||||
default: throw new Error(`Unknown spec value definition: ${value}`);
|
||||
}
|
||||
return byteLength;
|
||||
}
|
||||
|
||||
function decode_spec(buf, spec, o = {}, main = o, idx = 0) {
|
||||
|
||||
let byteLength = 0;
|
||||
for (let key of Object.keys(spec)) {
|
||||
const value = spec[key];
|
||||
const bytes = decode_spec_value(o, key, value, buf, idx, main);
|
||||
idx += bytes;
|
||||
byteLength += bytes;
|
||||
}
|
||||
|
||||
return byteLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a binary XML file back into a readable XML document
|
||||
* @param {Buffer} buf binary XMl content
|
||||
*/
|
||||
function decode_binary_xml(buf) {
|
||||
const xml_spec = {
|
||||
header: '0x00080003',
|
||||
headerSize: 4,
|
||||
stringPool: {
|
||||
header: '0x0001',
|
||||
hdrsize: '0x001c',
|
||||
sz: 4,
|
||||
stringCount: 4,
|
||||
styleCount: 4,
|
||||
flags: 4,
|
||||
stringStart: 4,
|
||||
styleStart: 4,
|
||||
stringOffsets: [{
|
||||
length: main => main.stringPool.stringCount,
|
||||
element_spec: 4,
|
||||
}],
|
||||
strings: [{
|
||||
length: main => main.stringPool.stringCount,
|
||||
element_spec: 'length-utf16-null',
|
||||
}],
|
||||
padding: 'align:4',
|
||||
},
|
||||
resourceIDPool: {
|
||||
hdr: '0x0180',
|
||||
hdrsize: '0x0008',
|
||||
sz: 4,
|
||||
resIDs: [{
|
||||
length: main => (main.resourceIDPool.sz - main.resourceIDPool.hdrsize) / 4,
|
||||
element_spec: 4,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = {};
|
||||
let idx = decode_spec(buf, xml_spec, decoded);
|
||||
|
||||
// after we've extracted the string and id's, it should be time to parse the xml
|
||||
const node_stack = [{ nodes: [] }];
|
||||
const namespaces = [];
|
||||
while (idx < buf.byteLength) {
|
||||
const id = buf.readUInt16LE(idx);
|
||||
switch (id) {
|
||||
case 0x0100: {
|
||||
// start namespace
|
||||
const node = {};
|
||||
idx += decode_spec(buf, START_NAMESPACE_SPEC, node, node, idx);
|
||||
namespaces.push(node);
|
||||
break;
|
||||
}
|
||||
case 0x0101: {
|
||||
// end namespace
|
||||
const node = {};
|
||||
idx += decode_spec(buf, END_NAMESPACE_SPEC, node, node, idx);
|
||||
const i = namespaces.findIndex(ns => ns.nameId === node.nameId);
|
||||
namespaces.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
case 0x0102: {
|
||||
// begin node
|
||||
const node = {};
|
||||
idx += decode_spec(buf, BEGIN_NODE_SPEC, node, node, idx);
|
||||
node.namespaces = namespaces.slice();
|
||||
node.namespaces.forEach(ns => {
|
||||
if (!ns.node) ns.node = node;
|
||||
});
|
||||
node.nodes = [];
|
||||
node_stack[0].nodes.push(node);
|
||||
node_stack.unshift(node);
|
||||
break;
|
||||
}
|
||||
case 0x0103: {
|
||||
// end node
|
||||
const spec = END_NODE_SPEC;
|
||||
const node = {};
|
||||
idx += decode_spec(buf, spec, node, node, idx);
|
||||
node_stack.shift();
|
||||
break;
|
||||
}
|
||||
default: throw new Error(`Unknown XML element ${id.toString(16)}`);
|
||||
}
|
||||
}
|
||||
decoded.nodes = node_stack[0].nodes;
|
||||
|
||||
const xml = toXMLDocument(decoded);
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the decoded binary XML to a readable XML document
|
||||
* @param {*} decoded
|
||||
*/
|
||||
function toXMLDocument(decoded) {
|
||||
const strings = decoded.stringPool.strings;
|
||||
const format = {
|
||||
nodes: (nodes, indent) => {
|
||||
return nodes.map(node => format.node(node, indent)).join('\n');
|
||||
},
|
||||
node: (node, indent) => {
|
||||
const parts = [indent, '<', strings[node.nameId]];
|
||||
for (let ns of node.namespaces.filter(ns => ns.node === node)) {
|
||||
parts.push(' ', `xmlns:${strings[ns.nameId]}="${strings[ns.valueId]}"`);
|
||||
}
|
||||
const attr_indent = node.attributes.length > 1 ? `\n${indent} ` : ' ';
|
||||
for (let attr of node.attributes) {
|
||||
parts.push(attr_indent, format.attribute(attr, node.namespaces));
|
||||
}
|
||||
if (node.nodes.length) {
|
||||
parts.push('>\n', format.nodes(node.nodes, indent + ' '), '\n', indent, '</', strings[node.nameId], '>');
|
||||
} else {
|
||||
parts.push(' />');
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
},
|
||||
attribute: (attr, namespaces) => {
|
||||
let value = attr.value;
|
||||
switch (attr.type) {
|
||||
case 3:
|
||||
value = strings[value];
|
||||
break;
|
||||
case 16:
|
||||
value |= 0; // convert to signed integer
|
||||
break;
|
||||
case 18:
|
||||
value = value ? true : false;
|
||||
break;
|
||||
case 1: // resource id
|
||||
case 17: // flags
|
||||
default:
|
||||
value = '0x' + value.toString(`16`);
|
||||
break;
|
||||
}
|
||||
let ns = '';
|
||||
if (attr.ns >= 0) {
|
||||
ns = `${strings[namespaces.find(ns => ns.valueId === attr.ns).nameId]}:`;
|
||||
}
|
||||
return `${ns}${strings[attr.nameId]}="${value}"`;
|
||||
}
|
||||
}
|
||||
return '<?xml version="1.0" encoding="utf-8"?>\n' + format.nodes(decoded.nodes, '');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decode_binary_xml,
|
||||
}
|
||||
@@ -44,7 +44,7 @@ class AndroidContentProvider /*extends TextDocumentContentProvider*/ {
|
||||
provideLogcatDocumentContent(uri) {
|
||||
// LogcatContent depends upon AndroidContentProvider, so we must delay-load this
|
||||
const { LogcatContent } = require('./logcat');
|
||||
var doc = this._docs[uri] = new LogcatContent(this, uri);
|
||||
var doc = this._docs[uri] = new LogcatContent(uri.query);
|
||||
return doc.content;
|
||||
}
|
||||
}
|
||||
|
||||
136
src/debugMain.js
136
src/debugMain.js
@@ -10,14 +10,16 @@ const dom = require('xmldom').DOMParser;
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const unzipper = require('unzipper');
|
||||
const xpath = require('xpath');
|
||||
|
||||
// our stuff
|
||||
const { ADBClient } = require('./adbclient');
|
||||
const { decode_binary_xml } = require('./apkdecoder');
|
||||
const { Debugger } = require('./debugger');
|
||||
const $ = require('./jq-promise');
|
||||
const { AndroidThread } = require('./threads');
|
||||
const { D, isEmptyObject } = require('./util');
|
||||
const { D, onMessagePrint, isEmptyObject } = require('./util');
|
||||
const { AndroidVariables } = require('./variables');
|
||||
const { evaluate } = require('./expressions');
|
||||
const ws_proxy = require('./wsproxy').proxy.Server(6037, 5037);
|
||||
@@ -45,6 +47,8 @@ class AndroidDebugSession extends DebugSession {
|
||||
this.src_packages = {};
|
||||
// the device we are debugging
|
||||
this._device = null;
|
||||
// the full file path name of the AndroidManifest.xml, taken from the manifestFile launch property
|
||||
this.manifest_fpn = '';
|
||||
|
||||
// the threads (we know about from the last refreshThreads call)
|
||||
// this is implemented as both a hashmap<threadid,AndroidThread> and an array of AndroidThread objects
|
||||
@@ -73,6 +77,9 @@ class AndroidDebugSession extends DebugSession {
|
||||
// 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;
|
||||
|
||||
// this debugger uses one-based lines and columns
|
||||
this.setDebuggerLinesStartAt1(true);
|
||||
this.setDebuggerColumnsStartAt1(true);
|
||||
@@ -106,14 +113,19 @@ class AndroidDebugSession extends DebugSession {
|
||||
}
|
||||
|
||||
LOG(msg) {
|
||||
D(msg);
|
||||
if (!this.trace) {
|
||||
D(msg);
|
||||
}
|
||||
// VSCode no longer auto-newlines output
|
||||
this.sendEvent(new OutputEvent(msg + os.EOL));
|
||||
}
|
||||
|
||||
WARN(msg) {
|
||||
D(msg = 'Warning: '+msg);
|
||||
this.sendEvent(new OutputEvent(msg + os.EOL));
|
||||
// the message will already be sent if trace is enabled
|
||||
if (!this.trace) {
|
||||
this.sendEvent(new OutputEvent(msg + os.EOL));
|
||||
}
|
||||
}
|
||||
|
||||
failRequest(msg, response) {
|
||||
@@ -223,11 +235,17 @@ class AndroidDebugSession extends DebugSession {
|
||||
}
|
||||
|
||||
launchRequest(response/*: DebugProtocol.LaunchResponse*/, args/*: LaunchRequestArguments*/) {
|
||||
if (args && args.trace) {
|
||||
this.trace = args.trace;
|
||||
onMessagePrint(this.LOG.bind(this));
|
||||
}
|
||||
|
||||
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;
|
||||
this.manifest_fpn = args.manifestFile;
|
||||
this.pmInstallArgs = args.pmInstallArgs;
|
||||
if (typeof args.callStackDisplaySize === 'number' && args.callStackDisplaySize >= 0)
|
||||
this.callStackDisplaySize = args.callStackDisplaySize|0;
|
||||
|
||||
@@ -360,6 +378,9 @@ class AndroidDebugSession extends DebugSession {
|
||||
this.sendResponse(response);
|
||||
return this.dbgr.resume();
|
||||
})
|
||||
.then(() => {
|
||||
this.LOG('Application started');
|
||||
})
|
||||
.fail(e => {
|
||||
// exceptions use message, adbclient uses msg
|
||||
this.LOG('Launch failed: '+(e.message||e.msg||'No additional information is available'));
|
||||
@@ -377,16 +398,19 @@ class AndroidDebugSession extends DebugSession {
|
||||
copyAndInstallAPK() {
|
||||
// copy the file to the device
|
||||
this.LOG('Deploying current build...');
|
||||
const device_apk_fpn = '/data/local/tmp/debug.apk';
|
||||
return this._device.adbclient.push_file({
|
||||
filepathname:'/data/local/tmp/debug.apk',
|
||||
filepathname:device_apk_fpn,
|
||||
filedata:this._apk_file_data,
|
||||
filemtime:new Date().getTime(),
|
||||
})
|
||||
.then(() => {
|
||||
// send the install command
|
||||
this.LOG('Installing...');
|
||||
const command = `pm install ${Array.isArray(this.pmInstallArgs) ? this.pmInstallArgs.join(' ') : '-r'} ${device_apk_fpn}`;
|
||||
D(command);
|
||||
return this._device.adbclient.shell_cmd({
|
||||
command:'pm install -r /data/local/tmp/debug.apk',
|
||||
command,
|
||||
untilclosed:true,
|
||||
})
|
||||
})
|
||||
@@ -398,6 +422,12 @@ class AndroidDebugSession extends DebugSession {
|
||||
if (m) {
|
||||
return $.Deferred().rejectWith(this, [new Error('Installation failed. ' + m[0])]);
|
||||
}
|
||||
// now the 'pm install' command can have user-defined arguments, we must check that the command
|
||||
// is not rejected because of bad values
|
||||
m = stdout.match(/^java.lang.IllegalArgumentException:.+/m);
|
||||
if (m) {
|
||||
return $.Deferred().rejectWith(this, [new Error('Installation failed. ' + m[0])]);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -417,7 +447,7 @@ class AndroidDebugSession extends DebugSession {
|
||||
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) => {
|
||||
this.readAndroidManifest((err, manifest) => {
|
||||
if (err) return done.rejectWith(this, [new Error('Manifest read error. ' + err.message)]);
|
||||
done.result.manifest = manifest;
|
||||
try {
|
||||
@@ -446,6 +476,64 @@ class AndroidDebugSession extends DebugSession {
|
||||
return done;
|
||||
}
|
||||
|
||||
readAndroidManifest(cb) {
|
||||
// Because of manifest merging and build-injected properties, the manifest compiled inside
|
||||
// the APK is frequently different from the AndroidManifest.xml source file.
|
||||
// We try to extract the manifest from 3 sources (in priority order):
|
||||
// 1. The 'manifestFile' launch configuration property
|
||||
// 2. The decoded manifest from the APK
|
||||
// 3. The AndroidManifest.xml file from the root of the source tree.
|
||||
|
||||
const readAPKManifest = (cb) => {
|
||||
D(`Reading APK Manifest`);
|
||||
const apk_manifest_chunks = [];
|
||||
function cb_once(err, manifest) {
|
||||
cb && cb(err, manifest);
|
||||
cb = null;
|
||||
}
|
||||
fs.createReadStream(this.apk_fpn)
|
||||
.pipe(unzipper.ParseOne(/^AndroidManifest\.xml$/))
|
||||
.on('data', chunk => {
|
||||
apk_manifest_chunks.push(chunk);
|
||||
})
|
||||
.once('error', err => {
|
||||
cb_once(err);
|
||||
})
|
||||
.once('end', () => {
|
||||
try {
|
||||
const manifest = decode_binary_xml(Buffer.concat(apk_manifest_chunks));
|
||||
D(`APK manifest read complete`);
|
||||
cb_once(null, manifest);
|
||||
} catch (err) {
|
||||
D(`APK manifest decode failed: ${err.message}`);
|
||||
cb_once(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const readSourceManifest = (cb) => {
|
||||
D(`Reading source manifest from ${this.app_src_root}`);
|
||||
fs.readFile(path.join(this.app_src_root, 'AndroidManifest.xml'), 'utf8', cb);
|
||||
}
|
||||
|
||||
// a value from the manifestFile overrides the default manifest extraction
|
||||
// note: there's no validation that the file is a valid AndroidManifest.xml file
|
||||
if (this.manifest_fpn) {
|
||||
D(`Reading manifest from ${this.manifest_fpn}`);
|
||||
fs.readFile(this.manifest_fpn, 'utf8', cb);
|
||||
return;
|
||||
}
|
||||
|
||||
readAPKManifest((err, manifest) => {
|
||||
if (err) {
|
||||
// if we fail to read the APK manifest, revert to the source manifest
|
||||
readSourceManifest(cb)
|
||||
return;
|
||||
}
|
||||
cb(err, manifest);
|
||||
});
|
||||
}
|
||||
|
||||
scanSourceSync(app_root) {
|
||||
try {
|
||||
// scan known app folders looking for file changes and package folders
|
||||
@@ -468,12 +556,12 @@ class AndroidDebugSession extends DebugSession {
|
||||
}
|
||||
catch (err) { continue }
|
||||
// ignore folders not starting with a known top-level Android folder
|
||||
if (!/^(assets|res|src|main|java)([\\/]|$)/.test(p)) continue;
|
||||
if (!/^(assets|res|src|main|java|kotlin)([\\/]|$)/.test(p)) continue;
|
||||
// is this a package folder
|
||||
var pkgmatch = p.match(/^(src|main|java)[\\/](.+)/);
|
||||
var pkgmatch = p.match(/^(src|main|java|kotlin)[\\/](.+)/);
|
||||
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 src_folder = pkgmatch[1]; // src, main, java or kotlin
|
||||
const pkgname = pkgmatch[2].replace(/[\\/]/g,'.');
|
||||
src_packages.packages[pkgname] = {
|
||||
package: pkgname,
|
||||
@@ -521,6 +609,7 @@ class AndroidDebugSession extends DebugSession {
|
||||
}
|
||||
|
||||
configurationDoneRequest(response/*, args*/) {
|
||||
D('configurationDoneRequest');
|
||||
this.waitForConfigurationDone.resolve();
|
||||
this.sendResponse(response);
|
||||
}
|
||||
@@ -551,13 +640,14 @@ class AndroidDebugSession extends DebugSession {
|
||||
}
|
||||
|
||||
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;
|
||||
var verified = !!javabp.state.match(/set|enabled/);
|
||||
javabp.vsbp.verified = verified;
|
||||
javabp.vsbp.message = null;
|
||||
this.sendEvent(new BreakpointEvent('updated', javabp.vsbp));
|
||||
this.sendEvent(new BreakpointEvent('changed', javabp.vsbp));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -582,6 +672,14 @@ class AndroidDebugSession extends DebugSession {
|
||||
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)
|
||||
var srcfolder = path.dirname(srcfpn);
|
||||
var pkginfo;
|
||||
@@ -589,6 +687,14 @@ class AndroidDebugSession extends DebugSession {
|
||||
if ((pkginfo = this.src_packages.packages[pkg]).package_path === srcfolder) break;
|
||||
pkginfo = null;
|
||||
}
|
||||
// if we didn't find an exact path match, look for a case-insensitive match
|
||||
if (!pkginfo) {
|
||||
for (var pkg in this.src_packages.packages) {
|
||||
if ((pkginfo = this.src_packages.packages[pkg]).package_path.localeCompare(srcfolder, undefined, { sensitivity: 'base' }) === 0) break;
|
||||
pkginfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
// if it's not in our source packages, check if it's in the Android source file cache
|
||||
if (!pkginfo && is_subpath_of(srcfpn, this._android_sources_path)) {
|
||||
// create a fake pkginfo to use to construct the bp
|
||||
@@ -597,10 +703,7 @@ class AndroidDebugSession extends DebugSession {
|
||||
if (!pkginfo || !/\.(java|kt)$/i.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.breakpoints.map(bp => unverified_breakpoint(bp, 'The breakpoint location is not valid'))
|
||||
};
|
||||
this.sendResponse(response);
|
||||
sendBPResponse(response, args.breakpoints.map(bp => unverified_breakpoint(bp, 'The breakpoint location is not valid')));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -667,10 +770,7 @@ class AndroidDebugSession extends DebugSession {
|
||||
this._setup_breakpoints(this._queue[0]).then(javabp_arr => {
|
||||
// send back the VS Breakpoint instances
|
||||
var response = this._queue[0].response;
|
||||
response.body = {
|
||||
breakpoints: javabp_arr.map(javabp => javabp.vsbp)
|
||||
};
|
||||
this._dbgr.sendResponse(response);
|
||||
sendBPResponse(response, javabp_arr.map(javabp => javabp.vsbp));
|
||||
// .. and do the next one
|
||||
this._queue.shift();
|
||||
this._next();
|
||||
|
||||
@@ -1425,6 +1425,15 @@ Debugger.prototype = {
|
||||
cmd: this.JDWP.Commands.lineTable(methodinfo.owningclass, methodinfo),
|
||||
})
|
||||
.then(function (linetable, methodinfo) {
|
||||
// if the request failed, just return a blank table
|
||||
if (linetable.errorcode) {
|
||||
linetable = {
|
||||
errorcode: linetable.errorcode,
|
||||
start: '00000000000000000000000000000000',
|
||||
end: '00000000000000000000000000000000',
|
||||
lines:[],
|
||||
}
|
||||
}
|
||||
// the linetable does not correlate code indexes with line numbers
|
||||
// - location searching relies on the table being ordered by code indexes
|
||||
linetable.lines.sort(function (a, b) {
|
||||
|
||||
11
src/jdwp.js
11
src/jdwp.js
@@ -1,5 +1,5 @@
|
||||
const $ = require('./jq-promise');
|
||||
const { atob,btoa,D,getutf8bytes,fromutf8bytes,intToHex } = require('./util');
|
||||
const { btoa,D,E,getutf8bytes,fromutf8bytes,intToHex } = require('./util');
|
||||
/*
|
||||
JDWP - The Java Debug Wire Protocol
|
||||
*/
|
||||
@@ -96,8 +96,8 @@ function _JDWP() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.errorcode != 0) {
|
||||
console.error("Command failed: error " + this.errorcode, this);
|
||||
if (this.errorcode !== 0) {
|
||||
E(`JDWP command failed '${this.command.name}'. Error ${this.errorcode}`, this);
|
||||
}
|
||||
|
||||
if (!this.errorcode && this.command && this.command.replydecodefn) {
|
||||
@@ -109,7 +109,10 @@ function _JDWP() {
|
||||
return;
|
||||
}
|
||||
|
||||
this.decoded = {empty:true};
|
||||
this.decoded = {
|
||||
empty: true,
|
||||
errorcode: this.errorcode,
|
||||
};
|
||||
}
|
||||
|
||||
this.decodereply = function(ths,s) {
|
||||
|
||||
@@ -40,7 +40,7 @@ var Deferred = exports.Deferred = function(p, parent) {
|
||||
var faildef = $.Deferred(null, this);
|
||||
var p = this._promise.catch(function(a) {
|
||||
if (a.stack) {
|
||||
console.error(a.stack);
|
||||
util.E(a.stack);
|
||||
a = [a];
|
||||
}
|
||||
if (this.def._context === null && this.def._parent)
|
||||
|
||||
@@ -15,10 +15,8 @@ const { D } = require('./util');
|
||||
*/
|
||||
class LogcatContent {
|
||||
|
||||
constructor(provider/*: AndroidContentProvider*/, uri/*: Uri*/) {
|
||||
this._provider = provider;
|
||||
this._uri = uri;
|
||||
this._logcatid = uri.query;
|
||||
constructor(deviceid) {
|
||||
this._logcatid = deviceid;
|
||||
this._logs = [];
|
||||
this._htmllogs = [];
|
||||
this._oldhtmllogs = [];
|
||||
@@ -27,7 +25,7 @@ class LogcatContent {
|
||||
this._refreshRate = 200; // ms
|
||||
this._state = '';
|
||||
this._htmltemplate = '';
|
||||
this._adbclient = new ADBClient(uri.query);
|
||||
this._adbclient = new ADBClient(deviceid);
|
||||
this._initwait = new Promise((resolve, reject) => {
|
||||
this._state = 'connecting';
|
||||
LogcatContent.initWebSocketServer()
|
||||
@@ -79,8 +77,11 @@ class LogcatContent {
|
||||
});
|
||||
}
|
||||
sendClientMessage(msg) {
|
||||
var clients = LogcatContent._wss.clients.filter(client => client._logcatid === this._logcatid);
|
||||
clients.forEach(client => client.send(msg+'\n')); // include a newline to try and persuade a buffer write
|
||||
LogcatContent._wss.clients.forEach(client => {
|
||||
if (client._logcatid === this._logcatid) {
|
||||
client.send(msg + '\n'); // include a newline to try and persuade a buffer write
|
||||
}
|
||||
})
|
||||
}
|
||||
sendDisconnectMsg() {
|
||||
this.sendClientMessage(':disconnect');
|
||||
@@ -113,7 +114,7 @@ class LogcatContent {
|
||||
}
|
||||
updateLogs() {
|
||||
// no point in formatting the data if there are no connected clients
|
||||
var clients = LogcatContent._wss.clients.filter(client => client._logcatid === this._logcatid);
|
||||
var clients = [...LogcatContent._wss.clients].filter(client => client._logcatid === this._logcatid);
|
||||
if (clients.length) {
|
||||
var lines = '<div class="logblock">' + this._htmllogs.join('') + '</div>';
|
||||
clients.forEach(client => client.send(lines));
|
||||
@@ -189,14 +190,19 @@ LogcatContent.initWebSocketServer = function () {
|
||||
port: wssport,
|
||||
retries: 0,
|
||||
tryCreateWSS() {
|
||||
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port }, () => {
|
||||
const wsopts = {
|
||||
host: '127.0.0.1',
|
||||
port: this.port,
|
||||
clientTracking: true,
|
||||
};
|
||||
this.wss = new WebSocketServer(wsopts, () => {
|
||||
// success - save the info and resolve the deferred
|
||||
LogcatContent._wssport = this.port;
|
||||
LogcatContent._wssstartport = this.startport;
|
||||
LogcatContent._wss = this.wss;
|
||||
this.wss.on('connection', client => {
|
||||
this.wss.on('connection', (client, req) => {
|
||||
// the client uses the url path to signify which logcat data it wants
|
||||
client._logcatid = client.upgradeReq.url.match(/^\/?(.*)$/)[1];
|
||||
client._logcatid = req.url.match(/^\/?(.*)$/)[1];
|
||||
var lc = LogcatContent.byLogcatID[client._logcatid];
|
||||
if (lc) lc.onClientConnect(client);
|
||||
else client.close();
|
||||
@@ -276,6 +282,21 @@ function openLogcatWindow(vscode) {
|
||||
.then(devices => {
|
||||
if (!Array.isArray(devices)) return; // user cancelled (or no devices connected)
|
||||
devices.forEach(device => {
|
||||
if (vscode.window.createWebviewPanel) {
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
'androidlogcat', // Identifies the type of the webview. Used internally
|
||||
`logcat-${device.serial}`, // Title of the panel displayed to the user
|
||||
vscode.ViewColumn.One, // Editor column to show the new webview panel in.
|
||||
{
|
||||
enableScripts: true,
|
||||
}
|
||||
);
|
||||
const logcat = new LogcatContent(device.serial);
|
||||
logcat.content.then(html => {
|
||||
panel.webview.html = html;
|
||||
});
|
||||
return;
|
||||
}
|
||||
var uri = AndroidContentProvider.getReadLogcatUri(device.serial);
|
||||
return vscode.commands.executeCommand("vscode.previewHtml",uri,vscode.ViewColumn.Two);
|
||||
});
|
||||
|
||||
15
src/util.js
15
src/util.js
@@ -1,13 +1,18 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
var nofn = function () { };
|
||||
var D = exports.D = console.log.bind(console);
|
||||
var E = exports.E = console.error.bind(console);
|
||||
var W = exports.W = console.warn.bind(console);
|
||||
const messagePrintCallbacks = new Set();
|
||||
var D = exports.D = (...args) => (console.log(...args), messagePrintCallbacks.forEach(cb => cb(...args)))
|
||||
var E = exports.E = (...args) => (console.error(...args), messagePrintCallbacks.forEach(cb => cb(...args)))
|
||||
var W = exports.W = (...args) => (console.warn(...args), messagePrintCallbacks.forEach(cb => cb(...args)))
|
||||
var DD = nofn, cl = D, printf = D;
|
||||
var print_jdwp_data = nofn;// _print_jdwp_data;
|
||||
var print_packet = nofn;//_print_packet;
|
||||
|
||||
exports.onMessagePrint = function(cb) {
|
||||
messagePrintCallbacks.add(cb);
|
||||
}
|
||||
|
||||
Array.first = function (arr, fn, defaultvalue) {
|
||||
var idx = Array.indexOfFirst(arr, fn);
|
||||
return idx < 0 ? defaultvalue : arr[idx];
|
||||
@@ -622,9 +627,9 @@ exports.dumparr = function (arr, offset, count) {
|
||||
}
|
||||
|
||||
exports.btoa = function (arr) {
|
||||
return new Buffer(arr, 'binary').toString('base64');
|
||||
return Buffer.from(arr, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
exports.atob = function (base64) {
|
||||
return new Buffer(base64, 'base64').toString('binary');
|
||||
return Buffer.from(base64, 'base64').toString('binary');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user