Device.js


const Logger = require("./Logger.js")();
const _MD5_ = require("md5");
const _FS_ = require('fs');
const _path_ = require('path');

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

const DeviceProfile = require('./DeviceProfile');
const Platform = require('./Platform');
const PlatformManager = require('./PlatformManager');
const DexcaliburWorkspace = require('./DexcaliburWorkspace');
const Utils = require("./Utils");

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


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



 /**
 * This class represents a device
 * 
 * @class
 * @author Georges-B MICHEL
 */
class Device
{
    /**
     * 
     * @param {*} config 
     * @constructor
     */
    constructor(config=null){
        /**
         * @field
         */
        this.type = null;

        /**
         * Flag. TRUE if currently connected, else FALSE
         * 
         * @field
         */
        this.connected = false;

        /**
         * Default bridge for this devices
         * 
         * @field
         */
        this.bridge = null;

        /**
         * Flag. TRUE if this devices is default device for instrumentation
         * @field
         * @deprecated 
         */
        this.selected = false;
        
        /**
         * @field
         * @deprecated
         */
        this.isEmulated = false;

        /**
         * Device internal UID
         * @field
         */
        this.uid = null;

        /**
         * Real device ID
         * @field
         */
        this.id =  null;

        /**
         * TRUE if debugging is authorized, else FALSE
         * @field
         */
        this.authorized = true;

        /**
         * Device model
         * @field
         */
        this.model = null;

        /**
         * Device product name
         * @field
         */
        this.product = null;

        // ??s
        this.device = null;

        /**
         * Transport ID
         * 
         * @field
         * @deprecated
         */
        this.transportId = null;

        /**
         * USB qualifier.
         * Change when computer-side USB port change. It help to differentiate 
         * several devices with same DeviceID
         *   
         * @field
         */
        this.usbQualifier = null;

        /**
         * Device profile built by DeviceProfiler
         * @type {DeviceProfile}
         * @field
         */
        this.profile = null;

        /**
         * Device profile built by DeviceProfiler
         * @type {DeviceProfile}
         * @field
         */
        this.platform = null;

        /**
         * Hold frida configuration specfic to the device
         * @type {Object}
         * @field
         */
        this.frida = {
            server: null
        };

        /**
         * Hold all bridges (adb+usb, adb+tcp, sdb+usb, ssh, jtag, ...) configured for this device
         * 
         * @type {AdbWrapper[]}
         * @field
         */
        this.bridges = {};

        /**
         * Flag. TRUE is the device is enrolled, else FALSE
         * @field
         */
        this.enrolled = false;

        /**
         * Flag. TRUE is the device is offline, else FALSE
         * @field
         */
        this.offline = false;


        if(config !== null)
            for(let i in config) this[i] = config[i];    
    }

    /**
     * To add a bridge to the device
     * 
     * A bridge a way to send command or interact with the device.
     * 
     * @param {AdbWrapper} pBridge 
     * @method
     */
    addBridge( pBridge, pOverride=false){
        if(this.bridges[ pBridge.shortname ] == null || pOverride){
            this.bridges[ pBridge.shortname ] = pBridge;
        }
    }

    getBridge( pName){
        if(this.bridges[pName] == null)
            throw new Error(`[DEVICE] The device ${this.uid} not support bridge ${pName}`);

        return this.bridges[pName];
    }

    setDefaultBridge( pName){
        this.bridge = this.getBridge(pName);
        //this.setUID(this.bridge.deviceID);
    }

    getDefaultBridge(){
        return this.bridge;
    }
    

    setEnrolled( pStatus = true){
        this.enrolled = pStatus;

        return this;
    }

    isEnrolled(){
        return this.enrolled;
    }

    getProfile(){
        return this.profile;
    }

    /**
     * To get enrollment status
     * 
     * @returns {Boolean} Enrollement status : TRUE if the device is enrolled and frida ready, else FALSE
     * @method
     */
    isFridaReady(){
        return this.enrolled;
    }

    /**
     * To get device status : connected / disconnected
     * 
     * @returns {Boolean} TRUE if the device is connected, else FALSE  
     * @method
     */
    isConnected(){
        let up = false;
        for(let i in this.bridges) up |= this.bridges[i].up;
        //return (this.connected == true);
        return up;
    }

    /**
    * To get authorized status
    * 
    * @returns {Boolean} TRUE if the device is authorized, else FALSE  
    * @method
    */
    isAuthorized(){
        return (this.authorized == true);
    }

    /**
     * To disconnect "logically" a device.
     * 
     * This flag is involved into connected device monitoring.
     * 
     * @method
     */
    disconnect(){
        this.connected = false;
    }

    /**
     * 
     * @param {*} pPath 
     */
    setFridaServer( pPath){
        this.frida.server = pPath;
    }

    /**
     * @method
     */
    getFridaServerPath(){
        return this.frida.server;
    }

   

    /**
     * To setup internal device UID
     * 
     * Since several device can have the same DeviceID value,
     * UID is built by mixing several DeviceID with several data from `qualifier` array
     * 
     * 
     * @param {String} deviceID Value of DeviceID as returned by the device
     * @param {String[]} qualifier Additional data 
     */
    setUID(deviceID, qualifier){
        this.uid = deviceID;
/*        for(let k in qualifier){
            this.uid += "/"+k+"/"+qualifier[k];
        }*/
        //this.uid = _MD5_(this.uid);
    }


    /**
     * To get device UID
     * 
     * TODO : fix typo
     * 
     * <b>Warning : Device UID is the Dexcalibur internal UID. 
     * It is not the DeviceID as returned by the device. </b>
     * 
     * @returns {String} Internal device UID
     */
    getUID(){
        return this.uid;
    }

    update( pDevice){
        let b=null;

        if(this.id==null){
            this.id = pDevice.id;
        }

        this.bridge = pDevice.bridge;

        for(let i in pDevice.bridges){
            b = pDevice.bridges[i];
            this.bridges[i] = b;

            /*if(this.bridges[i] != null){
                this.bridges[i] = b;
            }else{
                this.bridges[i] = 
            }*/
        }

        this.model = pDevice.model;
        this.device = pDevice.device;
        this.product = pDevice.product;
        this.transportId = pDevice.transportId;
        // deprecated
        this.connected = pDevice.connected;
        this.authorized = pDevice.authorized;
        // deprecated
        this.usbQualifier = pDevice.usbQualifier;
    }

    merge( pDevice){
        for(let i in pDevice){
            switch(i){
                case 'enrolled':
                    if(pDevice.enrolled)
                        this.enrolled = pDevice.enrolled;
                    break;
                case 'bridges':
                    for(let t in pDevice.bridges){
                        if(this.bridges[t] == null){
                            this.bridges[t] = pDevice.bridges[t];
                        }
                    }
                    break;
                case 'authorized':
                    if(pDevice.authorized){
                        this.authorized = true;
                    }
                    break;
                case 'connected':
                    // deprecated
                    if(pDevice.connected){
                        this.connected = true;
                    }
                    break;
                case 'profile':
                    if(pDevice.profile != null){
                        this.profile = pDevice.profile;
                    }
                    break;
                case 'bridge':
                case 'platform':
                    //
                    break;
                case 'frida':
                    if(this.frida.server == null && pDevice.frida.server != null){
                        this.frida.server = pDevice.frida.server;
                    }
                    break;
                default:
                    if(this[i] == null && pDevice[i]!=null){
                        this[i] = pDevice[i];
                    }
                    break;
            }
        }

        if(pDevice.isConnected()){
            // TODO : add configurable priority TCP, USB, ... when several choices are possible
            /*
            if device passed as argument is connected now replace default bridge.
            avoid case : 
                1/ device identified by USB
                2/ connected over TCP
                3/ disconnected from USB
                4/ restart
                4'/ ADB restart ?
                5/ try USB connection when only TCP is available
            */
           if(this.bridge == null)
                this.setDefaultBridge(pDevice.bridge.shortname);
        }
        if(pDevice.enrolled){
            this.enrolled = pDevice.enrolled;
        }
        for(let i in pDevice.bridges){
            this.bridges[i] = pDevice.bridges[i]
        }
    }

    flagAsUnauthorized(){
        this.authorized = false;
    }

    setTransportId(id){
        this.transportId = id;
    }

    setUsbQualifier(id){
        this.usbQualifier = id;
        if(this.uid==null && this.id != null) 
            this.setUID( this.id, {
                usb: id
            });
    }

    setModel(name){
        this.model = name;
    }

    setProduct(name){
        this.product = name;
    }

    setDevice(name){
        this.device = name;
    }

    exec(pCommand, pCallbacks){
        return this.bridge.shellWithEH(pCommand, pCallbacks);
    }

    execSync(pCommand){
        return this.bridge.shellWithEHsync(pCommand);
    }


    async privilegedExecSync(pCommand, pOtions=null){
        if(pOtions == null)
            return await this.bridge.privilegedShell(pCommand);
        else 
            return await this.bridge.privilegedShell(pCommand, pOtions);
    }

    getPlatform(){
        return this.platform;
    }

    setPlatform( pPlatform){
        this.platform = pPlatform;
    }



    /**
     * 
     * @param {Path|String} pRemotePath 
     * @param {Path|String} pLocalPath 
     */ 
    pull(pRemotePath, pLocalPath){
        let c = null;
        c = this.bridge.pull(pRemotePath, pLocalPath);
        return c;
    }

    /**
     * To pull a fil from a device and store it into temporary folder
     * 
     * @param {String} pRemotePath 
     * @method
     */
    pullTemp(pRemotePath){
        if(this.bridge == null){
            throw new Error("[DEVICE] Bridge is not ready");
        }
        let path = _path_.join(
            DexcaliburWorkspace.getInstance().getTempFolderLocation(),
            Utils.randString( 16, Utils.ALPHANUM)+'.remote.apk'
        );

        let o = this.bridge.pull( pRemotePath, path);

        return path;
    }

    /**
     * To push an executable binary 
     * 
     * @param {Path|String} pLocalPath 
     * @param {Path|String} pRemotePath 
     */
    pushBinary( pLocalPath, pRemotePath){
        let success = this.bridge.push( pLocalPath, pRemotePath);
        if(!success){
            throw new Error(`[DEVICE] Fail to push '${pLocalPath}' file to '${pRemotePath}'`);
        }

        return this.bridge.shell(`chmod 777 ${pRemotePath}`);
    }

    /**
     * 
     * @param {*} pPkgIdentifier 
     * @param {*} pLocalPath 
     * @returns {Boolean} Return TRUE if file has been successfully downloaded, else FALSE
     * @throws {BridgeException} 
     */
    pullPackage( pPkgIdentifier, pLocalPath){
        let path = null;

        // get package path of the app
        path = this.bridge.getPackagePath(pPkgIdentifier);

        // pull the file
        this.bridge.pull( path, pLocalPath);       

        return _FS_.existsSync(pLocalPath);
    }



    /**
     * 
     * @deprecated
     * @param {Object} data 
     * @param {Object} callbacks 
     * @param {IntentFilter} pIntentFilter An intance of the intent filter 
     * @param {Boolean} force 
     */
    sendIntent(data, callbacks=null, pIntentFilter=null, force=false){
        let msg = {stdout:null, stderr:null};
        let pkg='', cmd='am start ';
        let act = null, cat=null;
        let cb = null;

        if(pIntentFilter==null){
            Logger.error("[TODO] Implement sendCustomIntent() : intent builder without autocompleting");
            callbacks.error("[TODO] Implement sendCustomIntent() : intent builder without autocompleting");
            return;
            // this.sendCustomIntent(data,callbacks);
        }

        if(data.category==null && force==false){
            if(pIntentFilter.getCategories().length-1 > 0){
                callbacks.error("This intent filter has several categories, and none is given");
                return;
            }
            else if(pIntentFilter.getCategories().length == 1){
                cat = pIntentFilter.getCategories()[0].getName();
            }
        }else{
            cat = data.category;
        }

        if(data.action==null && force == false){
            if(pIntentFilter.getActions().length-1 > 0){
                callbacks.error("This intent filter has several action, and none is given");
                return;
            }
            else if(pIntentFilter.getActions().length == 1){
                act = pIntentFilter.getActions()[0].getName();
            }
        }else{
            act = data.action;
        }

        if(callbacks != null){
            cb = function(err,stdout,stderr){
                if(err && callbacks.error!=null){
                    callbacks.error(err);
                }
                else if(stderr && callbacks.stderr!=null){
                    Logger.error(stderr);
                    callbacks.stderr(stderr);
                }else{
                    callbacks.stdout(stdout);
                }
            }
        }

        if(data.app !== null) 
            pkg = data.app;

        try{
            if(act != null && act.length > 0) cmd += `-a ${act} `;
            if(cat != null && cat.length > 0) cmd += `-c ${cat} `;
            if(data.data != null && data.data.length > 0) cmd += `-d ${data.data} `;
            
            msg.stdout = this.bridge.shellWithEH(cmd+' '+pkg, cb);
        }catch(err){
            msg.stderr = err.stderr;
            Logger.error("[INTENT]",err.stderr);
        }

        return msg;
    }


    /**
    * To check if the given file path exists on the device
    * @param {string} file The file path to check
    * @returns {boolean} Returns TRUE if the file exists on the device, else FALSE
    * @function 
    */
    hasFile(file, privileged=true){
        let ret="", i=0;

        if(privileged)
            ret = this.bridge.hasFilePrivileged(file).toString("ascii");
        else
            ret = this.bridge.hasFile(file).toString("ascii");
            
        return (ret.indexOf(file)==0);
    };


    async performProfiling( pOptions){

        if(this.bridge != null){
            this.profile = await this.bridge.performProfiling();
        }

        return true;
    }


    /**
     * To unserialize a Device from JSON string
     * 
     * @param {*} pJsonObject 
     * @param {*} pOverride 
     * @returns {String} JSON-serialized object
     * @method 
     */
    static fromJsonObject( pJsonObject, pOverride = {}){
        let dev = new Device();
        for(let i in pJsonObject){
            switch(i){
                case 'type':
                    dev[i] = OS_NAME.indexOf(pJsonObject[i]);
                    break;

                case 'bridges':
                    dev.bridges = {};
                    for( let j in pJsonObject.bridges){
                        // todo : replace AdbWrapeper by BridgeFactory
                        dev.bridges[j] = require("./AdbWrapper").fromJsonObject( pJsonObject.bridges[j]);
                    }
                    break;

                case 'profile':
                    dev[i] = ((pJsonObject[i] != null)? DeviceProfile.fromJsonObject(pJsonObject[i]) : null);
                    break;

                case 'platform':
                    dev[i] = ((pJsonObject[i] != null)? PlatformManager.getInstance().getPlatform(pJsonObject[i]) : null);
                    break;
                
                default:
                    dev[i] = pJsonObject[i];
                    break;
            }      
            
        }

        if(dev.bridge != null){
            dev.setDefaultBridge(dev.bridge);
        }
        
        for(let i in pOverride){
            dev[i] = pOverride[i];
        }

        return dev;
    }

    /**
     * To retrieve UID from device through shell 
     * 
     * @method
     */
    async retrieveUIDfromDevice(){
        if(this.isConnected()===false || this.offline===true) 
            throw new Error('Device is offline');

        let id = null;
        let {stdout, stderr} = await this.bridge.shellAsync('getprop ro.serialno');

        if(stderr != ''){
            throw new Error(stderr);
        }

        id = stdout.split(EOL);
        if(id[0] !== undefined){
            this.id = id[0];
        }else{
            Logger.debug('[DEVICE] DeviceID retrieved from device : ',id);
        }

        return true;
    }

    /**
     * To serialize the Device to JSON string
     * 
     * @param {Object} pOverride A collection overrided field
     * @returns {JsonObject} JSON-serialized object
     * @method 
     */
    toJsonObject( pOverride = {}, pExcludeList={}){
        let json = new Object();
        for(let i in this){
            if(pExcludeList[i] === false) continue;
            
            switch(i){
                case 'type':
                    json[i] = OS_NAME[this[i]];
                    break;

                case 'bridge':
                    if(this.bridge != null){
                        json[i] = this.bridge.shortname;                        
                    }
                    break;

                case 'bridges':
                    json.bridges = {};
                        // json.bridgeData = this.bridge.toJsonObject();
                    for(let k in this.bridges){
                        json.bridges[k] = this.bridges[k].toJsonObject( pExcludeList.bridge);     
                    };
                    break;

                case 'profile':
                    json[i] = ((this[i] instanceof DeviceProfile)? this[i].toJsonObject( pExcludeList.profile) : null);
                    break;

                case 'platform':
                    json[i] = ((this[i] instanceof Platform)? this[i].getUID() : null);
                    break;
            
                case 'connected':
                    json[i] = this.isConnected();
                    break;

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

        for(let i in pOverride){
            json[i] = pOverride[i];
        }
        return json;
    }
}

module.exports = Device;