# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

import logging
import re
import os
import tempfile
import time
import traceback

from distutils import dir_util

from devicemanager import DeviceManager, DMError
from mozprocess import ProcessHandler
import mozfile
import version_codes


class DeviceManagerADB(DeviceManager):
    """
    Implementation of DeviceManager interface that uses the Android "adb"
    utility to communicate with the device. Normally used to communicate
    with a device that is directly connected with the host machine over a USB
    port.
    """

    _haveRootShell = None
    _haveSu = None
    _suModifier = None
    _lsModifier = None
    _useZip = False
    _logcatNeedsRoot = False
    _pollingInterval = 0.01
    _packageName = None
    _tempDir = None
    _adb_version = None
    _sdk_version = None
    connected = False

    def __init__(self, host=None, port=5555, retryLimit=5, packageName='fennec',
                 adbPath=None, deviceSerial=None, deviceRoot=None,
                 logLevel=logging.ERROR, autoconnect=True, runAdbAsRoot=False,
                 serverHost=None, serverPort=None, **kwargs):
        DeviceManager.__init__(self, logLevel=logLevel,
                               deviceRoot=deviceRoot)
        self.host = host
        self.port = port
        self.retryLimit = retryLimit

        self._serverHost = serverHost
        self._serverPort = serverPort

        # the path to adb, or 'adb' to assume that it's on the PATH
        self._adbPath = adbPath or 'adb'

        # The serial number of the device to use with adb, used in cases
        # where multiple devices are being managed by the same adb instance.
        self._deviceSerial = deviceSerial

        # Some devices do no start adb as root, if allowed you can use
        # this to reboot adbd on the device as root automatically
        self._runAdbAsRoot = runAdbAsRoot

        if packageName == 'fennec':
            if os.getenv('USER'):
                self._packageName = 'org.mozilla.fennec_' + os.getenv('USER')
            else:
                self._packageName = 'org.mozilla.fennec_'
        elif packageName:
            self._packageName = packageName

        # verify that we can run the adb command. can't continue otherwise
        self._verifyADB()

        if autoconnect:
            self.connect()

    def connect(self):
        if not self.connected:
            # try to connect to the device over tcp/ip if we have a hostname
            if self.host:
                self._connectRemoteADB()

            # verify that we can connect to the device. can't continue
            self._verifyDevice()

            # Note SDK version
            try:
                proc = self._runCmd(["shell", "getprop", "ro.build.version.sdk"],
                                    timeout=self.short_timeout)
                self._sdk_version = int(proc.output[0])
            except (OSError, ValueError):
                self._sdk_version = 0
            self._logger.info("Detected Android sdk %d" % self._sdk_version)

            # Some commands require root to work properly, even with ADB (e.g.
            # grabbing APKs out of /data). For these cases, we check whether
            # we're running as root. If that isn't true, check for the
            # existence of an su binary
            self._checkForRoot()

            # can we use zip to speed up some file operations? (currently not
            # required)
            try:
                self._verifyZip()
            except DMError:
                pass

    def __del__(self):
        if self.host:
            self._disconnectRemoteADB()

    def shell(self, cmd, outputfile, env=None, cwd=None, timeout=None, root=False):
        # FIXME: this function buffers all output of the command into memory,
        # always. :(

        # If requested to run as root, check that we can actually do that
        if root:
            if self._haveRootShell is None and self._haveSu is None:
                self._checkForRoot()
            if not self._haveRootShell and not self._haveSu:
                raise DMError(
                    "Shell command '%s' requested to run as root but root "
                    "is not available on this device. Root your device or "
                    "refactor the test/harness to not require root." %
                    self._escapedCommandLine(cmd))

        # Getting the return code is more complex than you'd think because adb
        # doesn't actually return the return code from a process, so we have to
        # capture the output to get it
        if root and self._haveSu:
            cmdline = "su %s \"%s\"" % (self._suModifier,
                                        self._escapedCommandLine(cmd))
        else:
            cmdline = self._escapedCommandLine(cmd)
        cmdline += "; echo $?"

        # prepend cwd and env to command if necessary
        if cwd:
            cmdline = "cd %s; %s" % (cwd, cmdline)
        if env:
            envstr = '; '.join(map(lambda x: 'export %s=%s' % (x[0], x[1]), env.iteritems()))
            cmdline = envstr + "; " + cmdline

        # all output should be in stdout
        args = [self._adbPath]
        if self._serverHost is not None:
            args.extend(['-H', self._serverHost])
        if self._serverPort is not None:
            args.extend(['-P', str(self._serverPort)])
        if self._deviceSerial:
            args.extend(['-s', self._deviceSerial])
        args.extend(["shell", cmdline])

        def _timeout():
            self._logger.error("Timeout exceeded for shell call '%s'" % ' '.join(args))

        self._logger.debug("shell - command: %s" % ' '.join(args))
        proc = ProcessHandler(args, processOutputLine=self._log, onTimeout=_timeout)

        if not timeout:
            # We are asserting that all commands will complete in this time unless
            # otherwise specified
            timeout = self.default_timeout

        timeout = int(timeout)
        proc.run(timeout)
        proc.wait()
        output = proc.output

        if output:
            lastline = output[-1]
            if lastline:
                m = re.search('([0-9]+)', lastline)
                if m:
                    return_code = m.group(1)
                    for line in output:
                        outputfile.write(line + '\n')
                    outputfile.seek(-2, 2)
                    outputfile.truncate()  # truncate off the return code
                    return int(return_code)

        return None

    def forward(self, local, remote):
        """
        Forward socket connections.

        Forward specs are one of:
          tcp:<port>
          localabstract:<unix domain socket name>
          localreserved:<unix domain socket name>
          localfilesystem:<unix domain socket name>
          dev:<character device name>
          jdwp:<process pid> (remote only)
        """
        if not self._checkCmd(['forward', local, remote], timeout=self.short_timeout) == 0:
            raise DMError("Failed to forward socket connection.")

    def remove_forward(self, local=None):
        """
        Turn off forwarding of socket connection.
        """
        cmd = ['forward']
        if local is None:
            cmd.extend(['--remove-all'])
        else:
            cmd.extend(['--remove', local])
        if not self._checkCmd(cmd, timeout=self.short_timeout) == 0:
            raise DMError("Failed to remove connection forwarding.")

    def remount(self):
        "Remounts the /system partition on the device read-write."
        return self._checkCmd(['remount'], timeout=self.short_timeout)

    def devices(self):
        "Return a list of connected devices as (serial, status) tuples."
        proc = self._runCmd(['devices'])
        proc.output.pop(0)  # ignore first line of output
        devices = []
        for line in proc.output:
            result = re.match('(.*?)\t(.*)', line)
            if result:
                devices.append((result.group(1), result.group(2)))
        return devices

    def _connectRemoteADB(self):
        self._checkCmd(["connect", self.host + ":" + str(self.port)])

    def _disconnectRemoteADB(self):
        self._checkCmd(["disconnect", self.host + ":" + str(self.port)])

    def pushFile(self, localname, destname, retryLimit=None, createDir=True):
        # you might expect us to put the file *in* the directory in this case,
        # but that would be different behaviour from devicemanagerSUT. Throw
        # an exception so we have the same behaviour between the two
        # implementations
        retryLimit = retryLimit or self.retryLimit
        if self.dirExists(destname):
            raise DMError("Attempted to push a file (%s) to a directory (%s)!" %
                          (localname, destname))
        if not os.access(localname, os.F_OK):
            raise DMError("File not found: %s" % localname)

        proc = self._runCmd(["push", os.path.realpath(localname), destname],
                            retryLimit=retryLimit)
        if proc.returncode != 0:
            raise DMError("Error pushing file %s -> %s; output: %s" %
                          (localname, destname, proc.output))

    def mkDir(self, name):
        result = self._runCmd(["shell", "mkdir", name], timeout=self.short_timeout).output
        if len(result) and 'read-only file system' in result[0].lower():
            raise DMError("Error creating directory: read only file system")

    def pushDir(self, localDir, remoteDir, retryLimit=None, timeout=None):
        # adb "push" accepts a directory as an argument, but if the directory
        # contains symbolic links, the links are pushed, rather than the linked
        # files; we either zip/unzip or re-copy the directory into a temporary
        # one to get around this limitation
        retryLimit = retryLimit or self.retryLimit
        if self._useZip:
            self.removeDir(remoteDir)
            self.mkDirs(remoteDir + "/x")
            try:
                localZip = tempfile.mktemp() + ".zip"
                remoteZip = remoteDir + "/adbdmtmp.zip"
                proc = ProcessHandler(["zip", "-r", localZip, '.'], cwd=localDir,
                                      processOutputLine=self._log)
                proc.run()
                proc.wait()
                self.pushFile(localZip, remoteZip, retryLimit=retryLimit, createDir=False)
                mozfile.remove(localZip)
                data = self._runCmd(["shell", "unzip", "-o", remoteZip,
                                     "-d", remoteDir]).output[0]
                self._checkCmd(["shell", "rm", remoteZip],
                               retryLimit=retryLimit, timeout=self.short_timeout)
                if re.search("unzip: exiting", data) or re.search("Operation not permitted", data):
                    raise Exception("unzip failed, or permissions error")
            except:
                self._logger.warning(traceback.format_exc())
                self._logger.warning("zip/unzip failure: falling back to normal push")
                self._useZip = False
                self.pushDir(localDir, remoteDir, retryLimit=retryLimit, timeout=timeout)
        else:
            localDir = os.path.normpath(localDir)
            remoteDir = os.path.normpath(remoteDir)
            copyRequired = False
            if self._adb_version >= '1.0.36' and \
               os.path.isdir(localDir) and self.dirExists(remoteDir):
                # See do_sync_push in
                # https://android.googlesource.com/platform/system/core/+/master/adb/file_sync_client.cpp
                # Work around change in behavior in adb 1.0.36 where if
                # the remote destination directory exists, adb push will
                # copy the source directory *into* the destination
                # directory otherwise it will copy the source directory
                # *onto* the destination directory.
                #
                # If the destination directory does exist, push to its
                # parent directory.  If the source and destination leaf
                # directory names are different, copy the source directory
                # to a temporary directory with the same leaf name as the
                # destination so that when we push to the parent, the
                # source is copied onto the destination directory.
                localName = os.path.basename(localDir)
                remoteName = os.path.basename(remoteDir)
                if localName != remoteName:
                    copyRequired = True
                    tempParent = tempfile.mkdtemp()
                    newLocal = os.path.join(tempParent, remoteName)
                    dir_util.copy_tree(localDir, newLocal)
                    localDir = newLocal
                remoteDir = '/'.join(remoteDir.rstrip('/').split('/')[:-1])
            try:
                self._checkCmd(["push", localDir, remoteDir],
                               retryLimit=retryLimit, timeout=timeout)
            except:
                raise
            finally:
                if copyRequired:
                    mozfile.remove(tempParent)

    def dirExists(self, remotePath):
        self._detectLsModifier()
        data = self._runCmd(["shell", "ls", self._lsModifier, remotePath + '/'],
                            timeout=self.short_timeout).output

        if len(data) == 1:
            res = data[0]
            if "Not a directory" in res or "No such file or directory" in res:
                return False
        return True

    def fileExists(self, filepath):
        self._detectLsModifier()
        data = self._runCmd(["shell", "ls", self._lsModifier, filepath],
                            timeout=self.short_timeout).output
        if len(data) == 1:
            foundpath = data[0].decode('utf-8').rstrip()
            if foundpath == filepath:
                return True
        return False

    def removeFile(self, filename):
        if self.fileExists(filename):
            self._checkCmd(["shell", "rm", filename], timeout=self.short_timeout)

    def removeDir(self, remoteDir):
        if self.dirExists(remoteDir):
            self._checkCmd(["shell", "rm", "-r", remoteDir], timeout=self.short_timeout)
        else:
            self.removeFile(remoteDir.strip())

    def moveTree(self, source, destination):
        self._checkCmd(["shell", "mv", source, destination], timeout=self.short_timeout)

    def copyTree(self, source, destination):
        self._checkCmd(["shell", "dd", "if=%s" % source, "of=%s" % destination])

    def listFiles(self, rootdir):
        self._detectLsModifier()
        data = self._runCmd(["shell", "ls", self._lsModifier, rootdir],
                            timeout=self.short_timeout).output
        data[:] = [item.rstrip('\r\n') for item in data]
        if (len(data) == 1):
            if (data[0] == rootdir):
                return []
            if (data[0].find("No such file or directory") != -1):
                return []
            if (data[0].find("Not a directory") != -1):
                return []
            if (data[0].find("Permission denied") != -1):
                return []
            if (data[0].find("opendir failed") != -1):
                return []
            if (data[0].find("Device or resource busy") != -1):
                return []
        return data

    def getProcessList(self):
        ret = []
        p = self._runCmd(["shell", "ps"], timeout=self.short_timeout)
        if not p or not p.output or len(p.output) < 1:
            return ret
        # first line is the headers
        p.output.pop(0)
        for proc in p.output:
            els = proc.split()
            # We need to figure out if this is "user pid name" or
            # "pid user vsz stat command"
            if els[1].isdigit():
                ret.append(list([int(els[1]), els[len(els) - 1], els[0]]))
            else:
                ret.append(list([int(els[0]), els[len(els) - 1], els[1]]))
        return ret

    def fireProcess(self, appname, failIfRunning=False):
        """
        Starts a process

        returns: pid

        DEPRECATED: Use shell() or launchApplication() for new code
        """
        # strip out env vars
        parts = appname.split('"')
        if (len(parts) > 2):
            parts = parts[2:]
        return self.launchProcess(parts, failIfRunning)

    def launchProcess(self, cmd, outputFile="process.txt", cwd='', env='', failIfRunning=False):
        """
        Launches a process, redirecting output to standard out

        WARNING: Does not work how you expect on Android! The application's
        own output will be flushed elsewhere.

        DEPRECATED: Use shell() or launchApplication() for new code
        """
        if cmd[0] == "am":
            self._checkCmd(["shell"] + cmd)
            return outputFile

        acmd = ["-W"]
        cmd = ' '.join(cmd).strip()
        i = cmd.find(" ")
        # SUT identifies the URL by looking for :\\ -- another strategy to consider
        re_url = re.compile('^[http|file|chrome|about].*')
        last = cmd.rfind(" ")
        uri = ""
        args = ""
        if re_url.match(cmd[last:].strip()):
            args = cmd[i:last].strip()
            uri = cmd[last:].strip()
        else:
            args = cmd[i:].strip()
        acmd.append("-n")
        acmd.append(cmd[0:i] + "/org.mozilla.gecko.BrowserApp")
        if args != "":
            acmd.append("--es")
            acmd.append("args")
            acmd.append(args)
        if env != '' and env is not None:
            envCnt = 0
            # env is expected to be a dict of environment variables
            for envkey, envval in env.iteritems():
                acmd.append("--es")
                acmd.append("env" + str(envCnt))
                acmd.append(envkey + "=" + envval)
                envCnt += 1
        if uri != "":
            acmd.append("-d")
            acmd.append(uri)

        acmd = ["shell", ' '.join(map(lambda x: '"' + x + '"', ["am", "start"] + acmd))]
        self._logger.info(acmd)
        self._checkCmd(acmd)
        return outputFile

    def killProcess(self, appname, sig=None):
        try:
            self.shellCheckOutput(["am", "force-stop", appname], timeout=self.short_timeout)
        except:
            # no problem - will kill it instead
            self._logger.info("killProcess failed force-stop of %s" % appname)

        shell_args = ["shell"]
        if self._sdk_version >= version_codes.N:
            # Bug 1334613 - force use of root
            if self._haveRootShell is None and self._haveSu is None:
                self._checkForRoot()
            if not self._haveRootShell and not self._haveSu:
                raise DMError(
                    "killProcess '%s' requested to run as root but root "
                    "is not available on this device. Root your device or "
                    "refactor the test/harness to not require root." %
                    appname)
            if not self._haveRootShell:
                shell_args.extend(["su", self._suModifier])

        procs = self.getProcessList()
        for (pid, name, user) in procs:
            if name == appname:
                args = list(shell_args)
                args.append("kill")
                if sig:
                    args.append("-%d" % sig)
                args.append(str(pid))
                p = self._runCmd(args, timeout=self.short_timeout)
                if p.returncode != 0 and len(p.output) > 0 and \
                   'No such process' not in p.output[0]:
                    raise DMError("Error killing process "
                                  "'%s': %s" % (appname, p.output))

    def _runPull(self, remoteFile, localFile):
        """
        Pulls remoteFile from device to host
        """
        try:
            self._runCmd(["pull",  remoteFile, localFile])
        except (OSError, ValueError):
            raise DMError("Error pulling remote file '%s' to '%s'" % (remoteFile, localFile))

    def pullFile(self, remoteFile, offset=None, length=None):
        # TODO: add debug flags and allow for printing stdout
        with mozfile.NamedTemporaryFile() as tf:
            self._runPull(remoteFile, tf.name)
            # we need to reopen the file to get the written contents
            with open(tf.name) as tf2:
                # ADB pull does not support offset and length, but we can
                # instead read only the requested portion of the local file
                if offset is not None and length is not None:
                    tf2.seek(offset)
                    return tf2.read(length)
                elif offset is not None:
                    tf2.seek(offset)
                    return tf2.read()
                else:
                    return tf2.read()

    def getFile(self, remoteFile, localFile):
        self._runPull(remoteFile, localFile)

    def getDirectory(self, remoteDir, localDir, checkDir=True):
        localDir = os.path.normpath(localDir)
        remoteDir = os.path.normpath(remoteDir)
        copyRequired = False
        originalLocal = localDir
        if self._adb_version >= '1.0.36' and \
           os.path.isdir(localDir) and self.dirExists(remoteDir):
            # See do_sync_pull in
            # https://android.googlesource.com/platform/system/core/+/master/adb/file_sync_client.cpp
            # Work around change in behavior in adb 1.0.36 where if
            # the local destination directory exists, adb pull will
            # copy the source directory *into* the destination
            # directory otherwise it will copy the source directory
            # *onto* the destination directory.
            #
            # If the destination directory does exist, pull to its
            # parent directory. If the source and destination leaf
            # directory names are different, pull the source directory
            # into a temporary directory and then copy the temporary
            # directory onto the destination.
            localName = os.path.basename(localDir)
            remoteName = os.path.basename(remoteDir)
            if localName != remoteName:
                copyRequired = True
                tempParent = tempfile.mkdtemp()
                localDir = os.path.join(tempParent, remoteName)
            else:
                localDir = '/'.join(localDir.rstrip('/').split('/')[:-1])
        self._runCmd(["pull", remoteDir, localDir]).wait()
        if copyRequired:
            dir_util.copy_tree(localDir, originalLocal)
            mozfile.remove(tempParent)

    def validateFile(self, remoteFile, localFile):
        md5Remote = self._getRemoteHash(remoteFile)
        md5Local = self._getLocalHash(localFile)
        if md5Remote is None or md5Local is None:
            return None
        return md5Remote == md5Local

    def _getRemoteHash(self, remoteFile):
        """
        Return the md5 sum of a file on the device
        """
        with tempfile.NamedTemporaryFile() as f:
            self._runPull(remoteFile, f.name)

            return self._getLocalHash(f.name)

    def _setupDeviceRoot(self, deviceRoot):
        # user-specified device root, create it and return it
        if deviceRoot:
            self.mkDir(deviceRoot)
            return deviceRoot

        # we must determine the device root ourselves
        paths = [('/storage/sdcard0', 'tests'),
                 ('/storage/sdcard1', 'tests'),
                 ('/storage/sdcard', 'tests'),
                 ('/mnt/sdcard', 'tests'),
                 ('/sdcard', 'tests'),
                 ('/data/local', 'tests')]
        for (basePath, subPath) in paths:
            if self.dirExists(basePath):
                root = os.path.join(basePath, subPath)
                try:
                    self.mkDir(root)
                    return root
                except:
                    pass

        raise DMError("Unable to set up device root using paths: [%s]"
                      % ", ".join(["'%s'" % os.path.join(b, s) for b, s in paths]))

    def getTempDir(self):
        # Cache result to speed up operations depending
        # on the temporary directory.
        if not self._tempDir:
            self._tempDir = "%s/tmp" % self.deviceRoot
            self.mkDir(self._tempDir)

        return self._tempDir

    def reboot(self, wait=False, **kwargs):
        self._checkCmd(["reboot"])
        if wait:
            self._checkCmd(["wait-for-device"])
            if self._runAdbAsRoot:
                self._adb_root()
            self._checkCmd(["shell", "ls", "/sbin"], timeout=self.short_timeout)

    def updateApp(self, appBundlePath, **kwargs):
        return self._runCmd(["install", "-r", appBundlePath]).output

    def getCurrentTime(self):
        timestr = str(self._runCmd(["shell", "date", "+%s"], timeout=self.short_timeout).output[0])
        if (not timestr or not timestr.isdigit()):
            raise DMError("Unable to get current time using date (got: '%s')" % timestr)
        return int(timestr) * 1000

    def getInfo(self, directive=None):
        directive = directive or "all"
        ret = {}
        if directive == "id" or directive == "all":
            ret["id"] = self._runCmd(["get-serialno"], timeout=self.short_timeout).output[0]
        if directive == "os" or directive == "all":
            ret["os"] = self.shellCheckOutput(
                ["getprop", "ro.build.display.id"], timeout=self.short_timeout)
        if directive == "uptime" or directive == "all":
            uptime = self.shellCheckOutput(["uptime"], timeout=self.short_timeout)
            if not uptime:
                raise DMError("error getting uptime")
            m = re.match("up time: ((\d+) days, )*(\d{2}):(\d{2}):(\d{2})", uptime)
            if m:
                uptime = "%d days %d hours %d minutes %d seconds" % tuple(
                    [int(g or 0) for g in m.groups()[1:]])
            ret["uptime"] = uptime
        if directive == "process" or directive == "all":
            data = self.shellCheckOutput(["ps"], timeout=self.short_timeout)
            ret["process"] = data.split('\n')
        if directive == "systime" or directive == "all":
            ret["systime"] = self.shellCheckOutput(["date"], timeout=self.short_timeout)
        if directive == "memtotal" or directive == "all":
            meminfo = {}
            for line in self.pullFile("/proc/meminfo").splitlines():
                key, value = line.split(":")
                meminfo[key] = value.strip()
            ret["memtotal"] = meminfo["MemTotal"]
        if directive == "disk" or directive == "all":
            data = self.shellCheckOutput(
                ["df", "/data", "/system", "/sdcard"], timeout=self.short_timeout)
            ret["disk"] = data.split('\n')
        self._logger.debug("getInfo: %s" % ret)
        return ret

    def uninstallApp(self, appName, installPath=None):
        status = self._runCmd(["uninstall", appName]).output[0].strip()
        if status != 'Success':
            raise DMError("uninstall failed for %s. Got: %s" % (appName, status))

    def uninstallAppAndReboot(self, appName, installPath=None):
        self.uninstallApp(appName)
        self.reboot()

    def _runCmd(self, args, timeout=None, retryLimit=None):
        """
        Runs a command using adb
        If timeout is specified, the process is killed after <timeout> seconds.

        returns: instance of ProcessHandler
        """
        retryLimit = retryLimit or self.retryLimit
        finalArgs = [self._adbPath]
        if self._serverHost is not None:
            finalArgs.extend(['-H', self._serverHost])
        if self._serverPort is not None:
            finalArgs.extend(['-P', str(self._serverPort)])
        if self._deviceSerial:
            finalArgs.extend(['-s', self._deviceSerial])
        finalArgs.extend(args)
        self._logger.debug("_runCmd - command: %s" % ' '.join(finalArgs))
        if not timeout:
            timeout = self.default_timeout

        def _timeout():
            self._logger.error("Timeout exceeded for _runCmd call '%s'" % ' '.join(finalArgs))

        retries = 0
        while retries < retryLimit:
            proc = ProcessHandler(finalArgs, storeOutput=True,
                                  processOutputLine=self._log, onTimeout=_timeout)
            proc.run(timeout=timeout)
            proc.returncode = proc.wait()
            if proc.returncode is None:
                proc.kill()
                retries += 1
            else:
                return proc

    # timeout is specified in seconds, and if no timeout is given,
    # we will run until we hit the default_timeout specified in the __init__
    def _checkCmd(self, args, timeout=None, retryLimit=None):
        """
        Runs a command using adb and waits for the command to finish.
        If timeout is specified, the process is killed after <timeout> seconds.

        returns: returncode from process
        """
        retryLimit = retryLimit or self.retryLimit
        finalArgs = [self._adbPath]
        if self._serverHost is not None:
            finalArgs.extend(['-H', self._serverHost])
        if self._serverPort is not None:
            finalArgs.extend(['-P', str(self._serverPort)])
        if self._deviceSerial:
            finalArgs.extend(['-s', self._deviceSerial])
        finalArgs.extend(args)
        self._logger.debug("_checkCmd - command: %s" % ' '.join(finalArgs))
        if not timeout:
            # We are asserting that all commands will complete in this
            # time unless otherwise specified
            timeout = self.default_timeout

        def _timeout():
            self._logger.error("Timeout exceeded for _checkCmd call '%s'" % ' '.join(finalArgs))

        timeout = int(timeout)
        retries = 0
        while retries < retryLimit:
            proc = ProcessHandler(finalArgs, processOutputLine=self._log, onTimeout=_timeout)
            proc.run(timeout=timeout)
            ret_code = proc.wait()
            if ret_code is None:
                proc.kill()
                retries += 1
            else:
                return ret_code

        raise DMError("Timeout exceeded for _checkCmd call after %d retries." % retries)

    def chmodDir(self, remoteDir, mask="777"):
        if (self.dirExists(remoteDir)):
            if '/sdcard' in remoteDir:
                self._logger.debug("chmod %s -- skipped (/sdcard)" % remoteDir)
            else:
                files = self.listFiles(remoteDir.strip())
                for f in files:
                    remoteEntry = remoteDir.strip() + "/" + f.strip()
                    if (self.dirExists(remoteEntry)):
                        self.chmodDir(remoteEntry)
                    else:
                        self._checkCmd(["shell", "chmod", mask, remoteEntry],
                                       timeout=self.short_timeout)
                        self._logger.info("chmod %s" % remoteEntry)
                self._checkCmd(["shell", "chmod", mask, remoteDir], timeout=self.short_timeout)
                self._logger.debug("chmod %s" % remoteDir)
        else:
            self._checkCmd(["shell", "chmod", mask, remoteDir.strip()], timeout=self.short_timeout)
            self._logger.debug("chmod %s" % remoteDir.strip())

    def _verifyADB(self):
        """
        Check to see if adb itself can be executed.
        """
        if self._adbPath != 'adb':
            if not os.access(self._adbPath, os.X_OK):
                raise DMError("invalid adb path, or adb not executable: %s" % self._adbPath)

        try:
            re_version = re.compile(r'Android Debug Bridge version (.*)')
            proc = self._runCmd(["version"], timeout=self.short_timeout)
            self._adb_version = re_version.match(proc.output[0]).group(1)
            self._logger.info("Detected adb %s" % self._adb_version)
        except os.error as err:
            raise DMError(
                "unable to execute ADB (%s): ensure Android SDK is installed "
                "and adb is in your $PATH" % err)

    def _verifyDevice(self):
        # If there is a device serial number, see if adb is connected to it
        if self._deviceSerial:
            deviceStatus = None
            for line in self._runCmd(["devices"]).output:
                m = re.match('(.+)?\s+(.+)$', line)
                if m:
                    if self._deviceSerial == m.group(1):
                        deviceStatus = m.group(2)
            if deviceStatus is None:
                raise DMError("device not found: %s" % self._deviceSerial)
            elif deviceStatus != "device":
                raise DMError("bad status for device %s: %s" % (self._deviceSerial, deviceStatus))

        # Check to see if we can connect to device and run a simple command
        if not self._checkCmd(["shell", "echo"], timeout=self.short_timeout) == 0:
            raise DMError("unable to connect to device")

    def _checkForRoot(self):
        self._haveRootShell = False
        self._haveSu = False
        # If requested to attempt to run adbd as root, do so before
        # checking whether adbs is running as root.
        if self._runAdbAsRoot:
            self._adb_root()

        # Check whether we _are_ root by default (some development boards work
        # this way, this is also the result of some relatively rare rooting
        # techniques)
        proc = self._runCmd(["shell", "id"], timeout=self.short_timeout)
        if proc.output and 'uid=0(root)' in proc.output[0]:
            self._haveRootShell = True
            # if this returns true, we don't care about su
            return

        # if root shell is not available, check if 'su' can be used to gain
        # root
        def su_id(su_modifier, timeout):
            proc = self._runCmd(["shell", "su", su_modifier, "id"],
                                timeout=timeout)

            # wait for response for maximum of 15 seconds, in case su
            # prompts for a password or triggers the Android SuperUser
            # prompt
            start_time = time.time()
            retcode = None
            while (time.time() - start_time) <= 15 and retcode is None:
                retcode = proc.poll()
            if retcode is None:  # still not terminated, kill
                proc.kill()

            if proc.output and 'uid=0(root)' in proc.output[0]:
                return True
            return False

        if su_id('0', self.short_timeout):
            self._haveSu = True
            self._suModifier = '0'
        elif su_id('-c', self.short_timeout):
            self._haveSu = True
            self._suModifier = '-c'

    def _isUnzipAvailable(self):
        data = self._runCmd(["shell", "unzip"]).output
        for line in data:
            if (re.search('Usage', line)):
                return True
        return False

    def _isLocalZipAvailable(self):
        def _noOutput(line):
            # suppress output from zip ProcessHandler
            pass
        try:
            proc = ProcessHandler(["zip", "-?"], storeOutput=False, processOutputLine=_noOutput)
            proc.run()
            proc.wait()
        except:
            return False
        return True

    def _verifyZip(self):
        # If "zip" can be run locally, and "unzip" can be run remotely, then pushDir
        # can use these to push just one file per directory -- a significant
        # optimization for large directories.
        self._useZip = False
        if (self._isUnzipAvailable() and self._isLocalZipAvailable()):
            self._logger.info("will use zip to push directories")
            self._useZip = True
        else:
            raise DMError("zip not available")

    def _adb_root(self):
        """ Some devices require us to reboot adbd as root.
            This function takes care of it.
        """
        if self.processInfo("adbd")[2] != "root":
            self._checkCmd(["root"])
            self._checkCmd(["wait-for-device"])
            if self.processInfo("adbd")[2] != "root":
                raise DMError("We tried rebooting adbd as root, however, it failed.")

    def _detectLsModifier(self):
        if self._lsModifier is None:
            # Check if busybox -1A is required in order to get one
            # file per line.
            output = self._runCmd(["shell", "ls", "-1A", "/"],
                                  timeout=self.short_timeout).output
            output = ' '.join(output)
            if 'error: device not found' in output:
                raise DMError(output)
            if "Unknown option '-1'. Aborting." in output:
                self._lsModifier = "-a"
            elif "No such file or directory" in output:
                self._lsModifier = "-a"
            else:
                self._lsModifier = "-1A"
