AdbWrapper.js

const Process = require("child_process");
const _path_ = require('path');

const UT = require("./Utils.js");
const Device = require("./Device.js");
const ApkPackage = require("./AppPackage");
const DeviceProfile = require('./DeviceProfile');
const {AdbWrapperError} = require("./Errors");

var Logger = require('./Logger.js')();



const EOL = require('os').EOL;
const _fs_ = require('fs');

const TRANSPORT = {
    USB: 'U',
    WIFI: 'W',
    TCP: 'T'
};

const emuRE = /^emulator-/;
const PROP_RE = /^\[(?<name>.*)\]\s*:\s*\[(?<value>.*)\]$/;

/*
const DEV = {
    USB: 0x1,
    EMU: 0x2,
    ADB: 0x3,
    SDB: 0x4
};
const DEV_NAME = ['unknow','udb','emu','adb','sdb'];
*/

const OS = {
    ANDROID: 0x0,
    LINUX: 0x1,
    TIZEN: 0x2
};
const OS_NAME = ['android','linux','tizen'];



/**
 * ADB wrapper
 * 
 * Can be use to manage/interact with a device connected through ADB
 * ADB Wrapper has two state :
 *  - Standard state : no device id passed to ADB
 *  - Specialized state : where all operation are done for a specific device ID 
 * 
 * @class 
 */
class AdbWrapper
{
    static USB_TRANSPORT = 'U';
    static TCP_TRANSPORT = 'T';
    
    /**
     * 
     * @param {String} adbpath The ADB binary path 
     * @param {String} pDeviceID  (optional) The device ID to manage.
     * @constructor
     */
    constructor(adbpath, pDeviceID = null){

        /**
         * @field
         * @since v0.7.2
         */
        this.shortname = null;

        /**
         * @field
         */
        this.transport = AdbWrapper.USB_TRANSPORT;

        /**
         * @type {Path}
         * @field
         */
        this.path = adbpath;

        /**
         * @field
         */
        this.deviceID = pDeviceID;

        /**
         * @field
         */
        this.ip = null;

        /**
         * @field
         */
        this.port = null;

        /**
         * @field
         */
        this.host = null;

        /**
         * @field
         */
        this.usbQualifier = null;

        /**
         * Bridge connection status
         * @field
         */
        this.up = false;
    }

    /**
     * To clone. 
     * 
     * It returns a new instance of AdbWrapper
     * 
     * @param {Object} pOverride Optional. Override configuration (key/value)
     * @returns {AdbWrapper} New instance with same configuration  
     * @method
     * @since v0.7.2
     */
    clone( pOverride = {}){
        let o = new AdbWrapper(this.path, this.deviceID);
        for(let i in this){
            if(pOverride[i] !== undefined){
                o[i] = pOverride[i];
            }else{
                o[i] = this[i];
            }   
        }
        return o;
    }

    /**
     * To get connection status
     * 
     * @returns {Boolean} TRUE is connected, else FALSE
     * @method
     */
    isConnected(){
        return this.up;
    }

    /**
     * 
     * @param {String} pIP IP Address
     * @method
     */
    setIpAddress(pIP){
        this.ip = pIP;
    }

    /**
     * 
     * @param {Integer} pNumber Port number
     * @method
     */
    setPortNumber(pNumber){
        this.port = pNumber;
    }

    /**
     * To check if ADB is ready to be used. 
     * 
     * Actually, it checks only if ADB path is not null :(
     * TODO : check ADB server state
     * 
     * @returns {Boolean} TRUE if ADB is ready to use, else FALSE
     * @method
     */
    isReady(){
        return (this.path != null) && (_fs_.existsSync(this.path));
    }

    /**
     * To init the next command, if a device ID is passed as arguments
     * then the command will use this device, else if a default device ID 
     * is configured the ID will be use, else no device ID is set. 
     * 
     * 
     * @param {String} deviceID The ID of the device to use 
     * @returns {String} The begin of the command
     * @method
     */
    setup(pDeviceID = null, pReturnString =  true){
        let cmd=null;

        if(pReturnString)
            cmd = this.path;
        else
            cmd = [];

        if(this.transport == AdbWrapper.USB_TRANSPORT){
            if(pDeviceID != null){

                if(pReturnString) 
                    cmd += " -s "+pDeviceID;
                else{
                    cmd.push("-s")
                    cmd.push(pDeviceID)
                }

            }else if(this.deviceID != null){

                if(pReturnString) 
                    cmd += " -s "+this.deviceID;
                else{
                    cmd.push("-s")
                    cmd.push(this.deviceID)
                }
            }
        }else if(this.transport == AdbWrapper.TCP_TRANSPORT){
            if(pReturnString) 
                cmd += " -s "+this.ip+':'+this.port;
            else{
                cmd.push("-s")
                cmd.push(this.ip+':'+this.port)
            }

        }

        return cmd;
    }

    /**
     * To kill adb-server
     * 
     * @async
     * @method
     */
    async kill(){
        let ret = null;

        ret = await UT.execAsync(this.setup() + " kill-server").catch((err)=>{
            throw new Error('[ADB WRAPPER] kill-server : '+err);
        });


        /*if(ret.stderr != null && ret.stderr.length > 0){
            throw new Error('[ADB WRAPPER] kill-server : '+ret.stderr);
        }*/


        return true;
    }

    /**
     * Set the transport type
     * 
     * @param {Char} transport_type 
     * @method
     */
    setTransport(transport_type){
        this.transport = transport_type;
    }

    /**
     * To check if the bridge uses TCP transport
     * 
     * @method
     * @returns {Boolean} TRUE if the wrapper is configured to use TCP, else FALSE
     * @since v0.7.1
     */
    isNetworkTransport(){
        return (this.transport === AdbWrapper.TCP_TRANSPORT);
    }

    /**
     * To check if the bridge uses USB transport

     * 
     * @method
     * @returns {Boolean} TRUE if the wrapper is configured to use TCP, else FALSE     * 
     * @since v0.7.1
     */
    isUsbTransport(){
        return (this.transport === AdbWrapper.USB_TRANSPORT);
    }

    /**
     * To connect a remote device over TCP
     * 
     * @param {String} pIpAddress IP Address of target device
     * @param {Integer} pPortNumber 
     * @param {String} pDeviceID Target device ID
     * @returns {Boolean} TRUE if success, else FALSE
     * @async
     * @method
     * @since v0.7.2
     */
    async connect( pIpAddress, pPortNumber, pDeviceID){
        let ret;
        ret = await UT.execAsync(this.setup(pDeviceID) + " tcpip "+pPortNumber);
        //Logger.debug(ret);

        ret = await UT.execAsync(this.setup(pDeviceID) + " connect "+pIpAddress+':'+pPortNumber);
        //console.log(ret);
        
        if(ret.stderr != null && ret.stderr.length > 0)
            return false;

        if(ret.stdout.indexOf(`connected to ${pIpAddress}`)==-1)
            return false;

        this.shortname = 'adb+tcp';
        this.transport = AdbWrapper.TCP_TRANSPORT;
        this.deviceID = pIpAddress+':'+pPortNumber;

        return true;
    }


    /**
     * To parse the ADB output. 
     * 
     * It returns a collection of ApkPackage.
     * ```
     > let packages = adbWrapper.parsePackageList(`
    package:com.android.cts.priv.ctsshim
    ...
    `) 
     > console.log(packages)
    [{
        packageIdentifier: 'com.android.cts.priv.ctsshim',
        packagePath: '...'
    },...]
     * ```  
     * 
     * @param {String} pPackageListStr The ADB output to parse 
     * @param {String} pOptions [Optional] Additional option to pass to ADB
     * @returns {ApkPackage[]} The list of package 
     * @private
     * @method
     */
    parsePackageList( pPackageListStr, pOptions=''){
        var reg = new RegExp("^package:(?<apk_name>.*)");
        var packages = [];

        if(pPackageListStr.indexOf("error:")==0){
            throw AdbWrapperError.newDeviceNotFound(`Unable to list package. ADB Error: "${pPackageListStr}"`);
        }

        pPackageListStr.split( EOL ).forEach(element => {
            var pkg = element.trim();
            let app, path = null;

            if(reg.test(pkg)) {
                var result  = reg.exec(pkg);
                if(result !== null) {
                    var pathResult = "";
                    
                    //recycle the same regex since the output is the same
                    //only take first match since this is the base apk
                    //pathResult = pathResult.split('\n')[0].trim();
                    if(reg.test(pathResult)) {
                        pathResult = reg.exec(pathResult).groups['apk_name'];
                    }

                    // package path arg
                    if(pOptions.indexOf('-f') > -1){
                        let i = result.groups['apk_name'].lastIndexOf("=");
                        path = result.groups['apk_name'].substr(0,i);
                        app = result.groups['apk_name'].substr(i+1);
                    }else{
                        path = null;
                        app = result.groups['apk_name']
                    }

                    packages.push(new ApkPackage({
                        packageIdentifier: app,
                        packagePath : path
                    }));
                }
            }
        });
        return packages;
    }

    /**
     * To list all packages installed on target device
     * 
     * @param {String} deviceId [Optional] A specific device ID
     * @returns {AppPackage[]} An array of AppPackage objects
     * @method
     */
    listPackages( pOtions) {
        let ret ="";

        ret = UT.execSync(this.setup() + " shell pm list packages "+pOtions).toString("ascii");

        return this.parsePackageList(ret, pOtions);
    }


    /*
     * 
     * @param {String} deviceId [Optional] A specific device ID
     
    listPackages(deviceId = null) {
        var reg = new RegExp("^package:(?<apk_name>.*)");
        var ret = "";
        if(deviceId !== null) {
            ret = Process.execSync(this.setup(deviceId) + " shell pm list packages").toString("ascii");
            
        }
        else {
            ret = Process.execSync(this.path + " shell pm list packages").toString("ascii");
            
        }
        var packages = [];
        ret.split( EOL ).forEach(element => {
            var pkg = element.trim();
            if(reg.test(pkg)) {
                var result  = reg.exec(pkg);
                if(result !== null) {
                    var pathResult = "";
                    //getting the path for each package takes ages
                    if(deviceId !== null) {
                       // pathResult = Process.execSync(this.setup(deviceId) + " shell pm path " + result.groups['apk_name']).toString("ascii");

                    }
                    else {
                       // pathResult = Process.execSync(this.path + " shell pm path " + result.groups['apk_name']).toString("ascii");
                    }
                    //recycle the same regex since the output is the same
                    //only take first match since this is the base apk
                    //pathResult = pathResult.split('\n')[0].trim();
                    if(reg.test(pathResult)) {
                        pathResult = reg.exec(pathResult).groups['apk_name'];
                    }
                    packages.push(new ApkPackage({
                        packageIdentifier: result.groups['apk_name'],
                        packagePath : pathResult,
                        
                    }));
                }
            }
        });
        return packages;
    }*/

    /**
     * To search the path of a specific package into the device
     * 
     * @param {String} packageIdentifier The package name of the application 
     * @param {String} deviceId (Optional) The ID of the device where search the package
     * @returns {String} The path of the application package into the device
     * @method
     */
    getPackagePath(packageIdentifier) {
        var reg = new RegExp("^package:(?<package_name>.*)");
        var ret = "";

        /*if(Process.env.DEXCALIBUR_ENV){
            ret = TestHelper.execSync(this.setup(deviceId) + " shell pm path " +  packageIdentifier).toString("ascii");
        }else
            ret = Process.execSync(this.setup(deviceId) + " shell pm path " +  packageIdentifier).toString("ascii");
*/
        ret = UT.execSync(this.setup() + " shell pm path " +  packageIdentifier).toString("ascii");

/*
        if(deviceId !== null) {
            ret = Process.execSync(this.setup(deviceId) + " shell pm path " +  packageIdentifier).toString("ascii");
            
        }
        else {
            ret = Process.execSync(this.path + " shell pm path " + packageIdentifier).toString("ascii");
        }*/

        var path = ret.split( require('os').EOL )[0].trim();
        if(reg.test(path)) {
            path = reg.exec(path).groups["package_name"];
            return path;
        }
        return "";
    }


    /**
     * To parse the output of "adb device -l" command
     *  
     * @param {String} pDeviceListStr the ouput of  "adb device -l" command
     * @returns {Device[]} An array of Device instances corresponding to ADB output
     * @method
     */
    async parseDeviceList( pDeviceListStr){
        let dev = [], ret=null,re=null, data=null, id=null, device=null, token=null;
        let bridge = null;

        Logger.debug(pDeviceListStr);

        ret = pDeviceListStr.split(require('os').EOL);
        
        re = new RegExp("^([^\\s\\t]+)[\\s\\t]+(.*)");
        

        for(let ln in ret){

            if(UT.trim(ret[ln]).length==0 
                || ret[ln]=="List of devices attached") 
                    continue;
    
            data =  re.exec(ret[ln]);

            if(data.length<3){
                Logger.warning("Invalid device id detected : ", ret[ln]);
                continue;
            }

            device = new Device();

            id = UT.parseIPv4(data[1], true);
            if(id.valid == false){
                // USB device, Device ID is returned by ADB 
                id = data[1];
                device.id = id;
                
                bridge = new AdbWrapper(this.path, id);
                bridge.transport = AdbWrapper.USB_TRANSPORT;
                bridge.shortname = 'adb+usb';

                Logger.debug('[DEVICE MANAGER][ADB] device ADB ID over USB : ', id);
            }else{
                // TCP device, unknow Device ID
                device.id = "<pending...>";
                bridge = new AdbWrapper(this.path, data[1]);

                bridge.transport = AdbWrapper.TCP_TRANSPORT;
                bridge.ip = id.ip;
                bridge.port = id.port;
                bridge.shortname = 'adb+tcp';

                Logger.debug('[DEVICE MANAGER][ADB] device ADB ID over TCP : ',data[1]);
            }

            device.addBridge(bridge);
            device.setDefaultBridge(bridge.shortname);


            id = data[1];
            data = data[2].split(" ");

//            device.setUID( 'adb:'+device.bridge.deviceID);
            //device.setUID( device.bridge.deviceID);

            // TODO : do it while profiling step
            device.type = OS.ANDROID;

            device.isEmulated = data[0].match(emuRE);
            // remove ?
            if(device.isEmulated){
                device.bridge.setTransport(AdbWrapper.TCP_TRANSPORT);
            }

            device.connected = true;
            device.getDefaultBridge().up = true;

            for(let i=0; i<data.length; i++){
                Logger.debug(`[DEVICE MANAGER] Parsing device list : ${data[i]}`);
                if(data[i].indexOf(':')>-1){
                    token = data[i].split(':',2);
                    switch(token[0]){
                        case 'usb':
                            device.bridge.usbQualifier = token[1];
                            break;
                        case 'model':
                            device.setModel(token[1]);
                            break;
                        case 'device':
                            device.setDevice(token[1]);
                            break;
                        case 'product':
                            device.setProduct(token[1]);
                            break;
                        case 'transport_id':
                            device.setTransportId(token[1]);
                            break;
                        default:
                            Logger.debug("Unrecognized key (dual token): "+token[0]);
                            break;
                    }

                }else{
                    switch(data[i]){
                        case 'unauthorized':
                            device.flagAsUnauthorized();
                            break;
                        case 'offline':
                            device.offline = true;;
                            device.connected = false;
                            device.getDefaultBridge().up = false;
                            break;
                        case 'device':
                        default:
                            Logger.debug("Unrecognized key (single token) : "+data[i]);
                            break;

                    }
                }
            }


            //console.log(device);
            if(device.bridge.shortname=='adb+tcp' && device.id == null){
                try{
                    await device.retrieveUIDfromDevice();
                }catch(err){
                    // catch Device offline but nothing to do
                    Logger.error("[ADB WRAPPER] List Devices : "+err.message);
                }
            }

            dev.push(device);
        }

        return dev;
    }

    /**
     * To list connected devices
     * 
     * @returns {Device[]} A collection of Device objects
     * @async
     * @method
     */
    async listDevices(){
        Logger.info("[ADB] Enumerating connected devices ...");
        return await this.parseDeviceList( 
            UT.execSync(this.setup()+" devices -l")
                .toString("ascii") );
    }

    

    /**
     * Pull a remote resource into the project workspace
     * Same as 'adb pull' commande.
     * 
     * @param {*} remote_path The path of the remote resource to download 
     * @param {*} local_path The path where the resource will be stored locally
     * @method
     */
    pull(remote_path, local_path){
        return UT.execSync(this.setup()+' pull '+remote_path+' '+local_path);
    }

    /**
     * Pull a remote resource into the project workspace with Application Privileges
     * Same as 'adb pull' commande.
     * 
     * @param {*} package_name The package name
     * @param {*} remote_path The path of the remote resource to download 
     * @param {*} local_path The path where the resource will be stored locally
     * @method
     */

    pullRessource(package_name,remote_path, local_path){
        if(deviceID != null) {
            var binary_blob = Process.execSync(this.setup() + 'shell "run-as '+ package_name+ ' cat ' + remote_path + '"').buffer;
            _fs_.writeFile(local_path,binary_blob,function(err) {
                if(err) {
                    Logger.error("[ADB] pullRessource() : an error occurs : "+err);
                }
            
                Logger.info("[ADB] The file was saved!");
            });
        }
    }
    /**
     * Push a local resource to a remote location
     * Same as 'adb push' commande.
     * 
     * @param {*} local_path The path of the local resource to upload 
     * @param {*} remote_path The path where the resource will be stored remotely
     * @method
     */
    push(local_path, remote_path){
            return UT.execSync(this.setup()+' push '+local_path+' '+remote_path);
    }


    /**
     * Execute a command on the device
     * Same as 'adb shell' commande.
     * 
     * @param {*} command The command to execute remotely
     * @method
     */
    shell(command, deviceID = null){
            return UT.execSync(this.setup()+' shell '+command);
    }

     /**
     * Execute a command on the device
     * Same as 'adb shell' commande.
     * 
     * @param {*} command The command to execute remotely
     * @async
     * @method
     */
    async shellAsync(command, deviceID = null){
        return await UT.execAsync(this.setup()+' shell '+command);
    }

    /**
     * Execute a command on the device
     * Same as 'adb shell' commande.
     * 
     * @param {*} command The command to execute remotely
     * @method
     */
    shellWithEH(command, callbacks=null){

        Logger.info("[ADB] ",this.setup()+' shell '+command);
        return Process.exec(this.setup()+' shell '+command, callbacks);

    }

    /**
     * Execute a command on the device
     * Same as 'adb shell' commande.
     * 
     * @param {*} command The command to execute remotely
     * @method
     */
    shellWithEHsync(command){

            Logger.info("[ADB] ",this.setup()+' shell '+command);
            return Process.execSync(this.setup()+' shell '+command);

    }

    /**
     * To execute a command into a detached process.
     * 
     * Useful to launch side application such as frida-server
     * 
     * @param {String} pCommand 
     * @param {String} pArgs
     * @returns {Boolean} TRUE is success, else FALSE
     * @method
     * @async  
     */
    async detachedShell( pCommand, pArgs = "" ){
        let args = this.setup(null,false);
        let ws = require('./DexcaliburWorkspace').getInstance();
        let out = _fs_.openSync( _path_.join( ws.getTempFolderLocation(), 'out.log'), 'w+', 0o666);
        let err = _fs_.openSync( _path_.join( ws.getTempFolderLocation(), 'err.log'), 'w+', 0o666);

        args = args.concat(pCommand);
        let child = Process.spawn(this.path, args, { detached: true, stdio: [ 'ignore', out, err ] });
        child.unref();

        return true;
    }


    /**
     * Execute a command on the device via 'su -c'
     * Same as 'adb shell su -c' commande.
     * 
     * @param {String} command The command to execute remotely
     * @async
     * @method
     */
    async privilegedShell(command, pOptions = {detached: false}){
        if(pOptions.detached)
            return await this.detachedShell(["shell","su","-c",command]);
        else
            return UT.execSync(this.setup()+' shell su -c "'+command+'"');
    }


    /**
     * To perform profiling of the device associated to this adb wrapper instance.
     *  
     * 
     * @returns {DeviceProfile} The device profile of target device
     * @method
     */
    performProfiling(){
        let self = this;
        let profile = new DeviceProfile();
        let prop = this.shellWithEHsync("getprop");
        //let prop = this.shellWithEHsync("");

        // GenericProfiler
        prop = prop.toString().split("\n");
        prop.map(( ppt)=>{
            let match = PROP_RE.exec(ppt);

            if(match != null)
                profile.addProperty(match.groups.name, match.groups.value);

        });


        // TeeProfiler

        return profile;
    }

    /**
     * 
     * @param {Object} pData Poor object
     * @returns {AdbWrapper} ADB wrapper instance
     * @method
     * @static 
     */
    static fromJsonObject( pData){
        let o = new AdbWrapper();
        for(let i in pData) o[i] = pData[i];
        return o;
    }

    /**
     * To tranform an instance to a simple object ready to be JSON serialized 
     * 
     * @param {Object} pExcludeList An hashmap key/value of property to exclude
     * @returns {Object} A simple object ready to be JSON serialized
     * @method
     */
    toJsonObject( pExcludeList={}){
        let o = new Object();

        for(let i in this){
            if(pExcludeList[i] === false) continue;
            
            switch(i)
            {
                case 'bridge':
                    if(this.bridge != null)
                        o.bridge = this.bridge.shortname;

                    break;
                default:
                    o[i] = this[i];
                    break;
            }
        } 

        return o;
    }
}



module.exports = AdbWrapper;