Bug 728298 - DeviceManager needs a good, standard way of starting an Android application. r=jmaher
authorWilliam Lachance <wlachance@mozilla.com>
Thu, 23 Feb 2012 10:32:28 -0500
changeset 87540 1de300d294f061afcd7bf1a195c2e08aee5cab17
parent 87539 ecb8127f2962ac9db05fbce9747940e5fa02f242
child 87541 ec8b8731343b513398222c2dc569199ddb3c7829
push id22130
push userrnewman@mozilla.com
push dateFri, 24 Feb 2012 02:35:54 +0000
treeherdermozilla-central@d23600a1d4a7 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjmaher
bugs728298
milestone13.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 728298 - DeviceManager needs a good, standard way of starting an Android application. r=jmaher
build/mobile/devicemanager.py
build/mobile/devicemanagerADB.py
build/mobile/devicemanagerSUT.py
build/mobile/droid.py
build/mobile/sutagent/android/DoCommand.java
--- a/build/mobile/devicemanager.py
+++ b/build/mobile/devicemanager.py
@@ -56,28 +56,36 @@ class DMError(Exception):
   "generic devicemanager exception."
 
   def __init__(self, msg= ''):
     self.msg = msg
 
   def __str__(self):
     return self.msg
 
-
 def abstractmethod(method):
   line = method.func_code.co_firstlineno
   filename = method.func_code.co_filename
   def not_implemented(*args, **kwargs):
     raise NotImplementedError('Abstract method %s at File "%s", line %s '
                               'should be implemented by a concrete class' %
                               (repr(method), filename,line))
   return not_implemented
-  
+
 class DeviceManager:
-  
+
+  @abstractmethod
+  def shell(self, cmd, outputfile, env=None, cwd=None):
+    """
+    executes shell command on device
+    returns:
+    success: Return code from command
+    failure: None
+    """
+
   @abstractmethod
   def pushFile(self, localname, destname):
     """
     external function
     returns:
     success: True
     failure: False
     """
@@ -163,35 +171,37 @@ class DeviceManager:
   @abstractmethod
   def getProcessList(self):
     """
     external function
     returns:
     success: array of process tuples
     failure: None
     """
-    
+
   @abstractmethod
   def fireProcess(self, appname, failIfRunning=False):
     """
     external function
+    DEPRECATED: Use shell() or launchApplication() for new code
     returns:
     success: pid
     failure: None
     """
-    
+
   @abstractmethod
   def launchProcess(self, cmd, outputFile = "process.txt", cwd = '', env = '', failIfRunning=False):
     """
     external function
+    DEPRECATED: Use shell() or launchApplication() for new code
     returns:
     success: output filename
     failure: None
     """
-    
+
   def communicate(self, process, timeout = 600, interval = 5):
     """
     loops until 'process' has exited or 'timeout' seconds is reached
     loop sleeps for 'interval' seconds between iterations
     external function
     returns:
     success: [file contents, None]
     failure: [None, None]
@@ -576,8 +586,40 @@ class NetworkTools:
           if seed > maxportnum:
             print "Could not find open port after checking 5000 ports"
           raise
         seed += 1
     except:
       print "Socket error trying to find open port"
         
     return seed
+
+def _pop_last_line(file):
+  '''
+  Utility function to get the last line from a file (shared between ADB and
+  SUT device managers). Function also removes it from the file. Intended to
+  strip off the return code from a shell command.
+  '''
+  bytes_from_end = 1
+  file.seek(0, 2)
+  length = file.tell() + 1
+  while bytes_from_end <= length:
+    file.seek((-1)*bytes_from_end, 2)
+    data = file.read()
+
+    if bytes_from_end == length and len(data) == 0: # no data, return None
+      return None
+
+    if data[0] == '\n' or bytes_from_end == length:
+      # found the last line, which should have the return value
+      if data[0] == '\n':
+        data = data[1:]
+
+      # truncate off the return code line
+      file.truncate(length - bytes_from_end)
+      file.seek(0,2)
+      file.write('\0')
+
+      return data
+
+    bytes_from_end += 1
+
+  return None
--- a/build/mobile/devicemanagerADB.py
+++ b/build/mobile/devicemanagerADB.py
@@ -1,10 +1,10 @@
 import subprocess
-from devicemanager import DeviceManager, DMError
+from devicemanager import DeviceManager, DMError, _pop_last_line
 import re
 import os
 import sys
 import tempfile
 
 class DeviceManagerADB(DeviceManager):
 
   def __init__(self, host = None, port = 20701, retrylimit = 5, packageName = None):
@@ -58,16 +58,57 @@ class DeviceManagerADB(DeviceManager):
         # to check again ourselves that we have root now.
         verifyRoot()
       except:
         if (self.useRunAs):
           print "restarting as root failed, but run-as available"
         else:
           print "restarting as root failed"
 
+  # external function: executes shell command on device
+  # returns:
+  # success: <return code>
+  # failure: None
+  def shell(self, cmd, outputfile, env=None, cwd=None):
+    # need to quote special characters here
+    for (index, arg) in enumerate(cmd):
+      if arg.find(" ") or arg.find("(") or arg.find(")") or arg.find("\""):
+        cmd[index] = '\'%s\'' % arg
+
+    # This 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
+    # FIXME: this function buffers all output of the command into memory,
+    # always. :(
+    cmdline = subprocess.list2cmdline(cmd) + "; 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
+    proc = subprocess.Popen(["adb", "shell", cmdline],
+                            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    (stdout, stderr) = proc.communicate()
+    outputfile.write(stdout.rstrip('\n'))
+
+    lastline = _pop_last_line(outputfile)
+    if lastline:
+      m = re.search('([0-9]+)', lastline)
+      if m:
+        return_code = m.group(1)
+        outputfile.seek(-2, 2)
+        outputfile.truncate() # truncate off the return code
+        return return_code
+
+    return None
+
   # external function
   # returns:
   #  success: True
   #  failure: False
   def pushFile(self, localname, destname):
     try:
       if (os.name == "nt"):
         destname = destname.replace('\\', '/')
@@ -259,27 +300,29 @@ class DeviceManagerADB(DeviceManager):
     ret = []
     while (proc):
       els = proc.split()
       ret.append(list([els[1], els[len(els) - 1], els[0]]))
       proc =  p.stdout.readline()
     return ret
 
   # external function
+  # DEPRECATED: Use shell() or launchApplication() for new code
   # returns:
   #  success: pid
   #  failure: None
   def fireProcess(self, appname, failIfRunning=False):
     #strip out env vars
     parts = appname.split('"');
     if (len(parts) > 2):
       parts = parts[2:]
     return self.launchProcess(parts, failIfRunning)
 
   # external function
+  # DEPRECATED: Use shell() or launchApplication() for new code
   # returns:
   #  success: output filename
   #  failure: None
   def launchProcess(self, cmd, outputFile = "process.txt", cwd = '', env = '', failIfRunning=False):
     if cmd[0] == "am":
       self.checkCmd(["shell"] + cmd)
       return outputFile
 
@@ -322,16 +365,17 @@ class DeviceManagerADB(DeviceManager):
   #  success: output from testagent
   #  failure: None
   def killProcess(self, appname):
     procs = self.getProcessList()
     for (pid, name, user) in procs:
       if name == appname:
         p = self.runCmdAs(["shell", "kill", pid])
         return p.stdout.read()
+
     return None
 
   # external function
   # returns:
   #  success: filecontents
   #  failure: None
   def catFile(self, remoteFile):
     #p = self.runCmd(["shell", "cat", remoteFile])
@@ -606,17 +650,17 @@ class DeviceManagerADB(DeviceManager):
 
   def runCmd(self, args):
     # If we are not root but have run-as, and we're trying to execute 
     # a shell command then using run-as is the best we can do
     if (not self.haveRoot and self.useRunAs and args[0] == "shell" and args[1] != "run-as"):
       args.insert(1, "run-as")
       args.insert(2, self.packageName)
     args.insert(0, "adb")
-    return subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    return subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
 
   def runCmdAs(self, args):
     if self.useRunAs:
       args.insert(1, "run-as")
       args.insert(2, self.packageName)
     return self.runCmd(args)
 
   def checkCmd(self, args):
--- a/build/mobile/devicemanagerSUT.py
+++ b/build/mobile/devicemanagerSUT.py
@@ -42,17 +42,28 @@ import SocketServer
 import time, datetime
 import os
 import re
 import hashlib
 import subprocess
 from threading import Thread
 import traceback
 import sys
-from devicemanager import DeviceManager, DMError, FileError, NetworkTools
+import StringIO
+from devicemanager import DeviceManager, DMError, FileError, NetworkTools, _pop_last_line
+
+class AgentError(Exception):
+  "SUTAgent-specific exception."
+
+  def __init__(self, msg= '', fatal = False):
+    self.msg = msg
+    self.fatal = fatal
+
+  def __str__(self):
+    return self.msg
 
 class DeviceManagerSUT(DeviceManager):
   host = ''
   port = 0
   debug = 2 
   retries = 0
   tempRoot = os.getcwd()
   base_prompt = '$>'
@@ -71,196 +82,237 @@ class DeviceManagerSUT(DeviceManager):
   def __init__(self, host, port = 20701, retrylimit = 5):
     self.host = host
     self.port = port
     self.retrylimit = retrylimit
     self.retries = 0
     self._sock = None
     self.getDeviceRoot()
 
-  def cmdNeedsResponse(self, cmd):
+  def _cmdNeedsResponse(self, cmd):
     """ Not all commands need a response from the agent:
         * if the cmd matches the pushRE then it is the first half of push
           and therefore we want to wait until the second half before looking
           for a response
         * rebt obviously doesn't get a response
         * uninstall performs a reboot to ensure starting in a clean state and
           so also doesn't look for a response
     """
     noResponseCmds = [re.compile('^push .*$'),
                       re.compile('^rebt'),
                       re.compile('^uninst .*$'),
                       re.compile('^pull .*$')]
 
     for c in noResponseCmds:
       if (c.match(cmd)):
         return False
-    
+
     # If the command is not in our list, then it gets a response
     return True
 
-  def shouldCmdCloseSocket(self, cmd):
+  def _stripPrompt(self, data):
+    '''
+    internal function
+    take a data blob and strip instances of the prompt '$>\x00'
+    '''
+    promptre = re.compile(self.prompt_regex + '.*')
+    retVal = []
+    lines = data.split('\n')
+    for line in lines:
+      foundPrompt = False
+      try:
+        while (promptre.match(line)):
+          foundPrompt = True
+          pieces = line.split(self.prompt_sep)
+          index = pieces.index('$>')
+          pieces.pop(index)
+          line = self.prompt_sep.join(pieces)
+      except(ValueError):
+        pass
+
+      # we don't want to append lines that are blank after stripping the
+      # prompt (those are basically "prompts")
+      if not foundPrompt or line:
+        retVal.append(line)
+
+    return '\n'.join(retVal)
+
+  def _shouldCmdCloseSocket(self, cmd):
     """ Some commands need to close the socket after they are sent:
     * push
     * rebt
     * uninst
     * quit
     """
-    
     socketClosingCmds = [re.compile('^push .*$'),
                          re.compile('^quit.*'),
                          re.compile('^rebt.*'),
                          re.compile('^uninst .*$')]
 
     for c in socketClosingCmds:
       if (c.match(cmd)):
         return True
 
     return False
 
-  # convenience function to enable checks for agent errors
-  def verifySendCMD(self, cmdline, newline = True):
-    return self.sendCMD(cmdline, newline, False)
-
-
-  #
-  # create a wrapper for sendCMD that loops up to self.retrylimit iterations.
-  # this allows us to move the retry logic outside of the _doCMD() to make it 
-  # easier for debugging in the future.
-  # note that since cmdline is a list of commands, they will all be retried if
-  # one fails.  this is necessary in particular for pushFile(), where we don't want
-  # to accidentally send extra data if a failure occurs during data transmission.
-  #
-  def sendCMD(self, cmdline, newline = True, ignoreAgentErrors = True):
+  def sendCmds(self, cmdlist, outputfile, timeout = None, newline = True):
+    '''
+    a wrapper for _doCmds that loops up to self.retrylimit iterations.
+    this allows us to move the retry logic outside of the _doCmds() to make it
+    easier for debugging in the future.
+    note that since cmdlist is a list of commands, they will all be retried if
+    one fails.  this is necessary in particular for pushFile(), where we don't want
+    to accidentally send extra data if a failure occurs during data transmission.
+    '''
     done = False
-    while (not done):
-      retVal = self._doCMD(cmdline, newline)
-      if (retVal is None):
+    while self.retries < self.retrylimit:
+      try:
+        self._doCmds(cmdlist, outputfile, timeout, newline)
+        return
+      except AgentError, err:
+        # re-raise error if it's fatal (i.e. the device got the command but
+        # couldn't execute it). retry otherwise
+        if err.fatal:
+          raise err
+        if self.debug >= 2:
+          print err
         self.retries += 1
-      else:
-        self.retries = 0
-        if ignoreAgentErrors == False:
-          if (self.agentErrorRE.match(retVal)):
-            raise DMError("error on the agent executing '%s'" % cmdline)
-        return retVal
+
+    raise AgentError("unable to connect to %s after %s attempts" % (self.host, self.retrylimit))
 
-      if (self.retries >= self.retrylimit):
-        done = True
+  def runCmds(self, cmdlist, timeout = None, newline = True):
+    '''
+    similar to sendCmds, but just returns any output as a string instead of
+    writing to a file. this is normally what you want to call to send a set
+    of commands to the agent
+    '''
+    outputfile = StringIO.StringIO()
+    self.sendCmds(cmdlist, outputfile, timeout, newline)
+    outputfile.seek(0)
+    return outputfile.read()
 
-    raise DMError("unable to connect to %s after %s attempts" % (self.host, self.retrylimit))        
-
-  def _doCMD(self, cmdline, newline = True):
+  def _doCmds(self, cmdlist, outputfile, timeout, newline):
     promptre = re.compile(self.prompt_regex + '$')
-    data = ""
     shouldCloseSocket = False
     recvGuard = 1000
 
-    if (self._sock == None):
+    if not self._sock:
       try:
-        if (self.debug >= 1):
+        if self.debug >= 1:
           print "reconnecting socket"
         self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
       except:
         self._sock = None
-        if (self.debug >= 2):
-          print "unable to create socket"
-        return None
-      
+        raise AgentError("unable to create socket")
+
       try:
         self._sock.connect((self.host, int(self.port)))
         self._sock.recv(1024)
       except:
         self._sock.close()
         self._sock = None
-        if (self.debug >= 2):
-          print "unable to connect socket"
-        return None
-    
-    for cmd in cmdline:
+        raise AgentError("unable to connect socket")
+
+    for cmd in cmdlist:
       if newline: cmd += '\r\n'
-      
+
       try:
         numbytes = self._sock.send(cmd)
         if (numbytes != len(cmd)):
-          print "ERROR: our cmd was " + str(len(cmd)) + " bytes and we only sent " + str(numbytes)
-          return None
+          raise AgentError("ERROR: our cmd was %s bytes and we only sent %s" % (len(cmd),
+                                                                                numbytes))
         if (self.debug >= 4): print "send cmd: " + str(cmd)
       except:
         self._sock.close()
         self._sock = None
-        return None
-      
+        return False
+
       # Check if the command should close the socket
-      shouldCloseSocket = self.shouldCmdCloseSocket(cmd)
+      shouldCloseSocket = self._shouldCmdCloseSocket(cmd)
 
       # Handle responses from commands
-      if (self.cmdNeedsResponse(cmd)):
+      if (self._cmdNeedsResponse(cmd)):
         found = False
         loopguard = 0
+        data = ""
 
         while (found == False and (loopguard < recvGuard)):
           temp = ''
           if (self.debug >= 4): print "recv'ing..."
 
           # Get our response
           try:
             temp = self._sock.recv(1024)
             if (self.debug >= 4): print "response: " + str(temp)
           except:
             self._sock.close()
             self._sock = None
-            return None
+            raise AgentError("Error receiving data from socket")
 
           data += temp
 
           # If something goes wrong in the agent it will send back a string that
           # starts with '##AGENT-ERROR##'
           if self.agentErrorRE.match(data):
-            break
+            raise AgentError("Agent Error processing command: %s" % cmd, fatal=True)
 
           for line in data.splitlines():
             if promptre.match(line):
               found = True
               data = self._stripPrompt(data)
               break
 
+          # periodically flush data to output file to make sure it doesn't get
+          # too big/unwieldly
+          if len(data) > 1024:
+              outputfile.write(data[0:1024])
+              data = data[1024:]
+
           # If we violently lose the connection to the device, this loop tends to spin,
           # this guard prevents that
           if (temp == ''):
             loopguard += 1
 
-    if (shouldCloseSocket == True):
+        # Write any remaining data to outputfile
+        outputfile.write(data)
+
+    if shouldCloseSocket:
       try:
         self._sock.close()
         self._sock = None
       except:
         self._sock = None
-        return None
+        raise AgentError("Error closing socket")
+
+  # external function: executes shell command on device
+  # returns:
+  # success: <return code>
+  # failure: None
+  def shell(self, cmd, outputfile, env=None, cwd=None):
+    cmdline = subprocess.list2cmdline(cmd)
+    if env:
+      cmdline = '%s %s' % (self.formatEnvString(env), cmdline)
 
-    return data
-  
-  # internal function
-  # take a data blob and strip instances of the prompt '$>\x00'
-  def _stripPrompt(self, data):
-    promptre = re.compile(self.prompt_regex + '.*')
-    retVal = []
-    lines = data.split('\n')
-    for line in lines:
-      try:
-        while (promptre.match(line)):
-          pieces = line.split(self.prompt_sep)
-          index = pieces.index('$>')
-          pieces.pop(index)
-          line = self.prompt_sep.join(pieces)
-      except(ValueError):
-        pass
-      retVal.append(line)
+    try:
+      if cwd:
+        self.sendCmds(['execcwd %s %s' % (cwd, cmdline)], outputfile)
+      else:
+        self.sendCmds(['exec %s' % cmdline], outputfile)
+    except AgentError:
+      return None
 
-    return '\n'.join(retVal)
-  
+    # dig through the output to get the return code
+    lastline = _pop_last_line(outputfile)
+    if lastline:
+      m = re.search('return code \[([0-9]+)\]', lastline)
+      if m:
+        return m.group(1)
+
+    # woops, we couldn't find an end of line/return value
+    return None
 
   # external function
   # returns:
   #  success: True
   #  failure: False
   def pushFile(self, localname, destname):
     if (os.name == "nt"):
       destname = destname.replace('\\', '/')
@@ -281,18 +333,18 @@ class DeviceManagerSUT(DeviceManager):
     if (self.debug >= 3): print "sending: push " + destname
     
     filesize = os.path.getsize(localname)
     f = open(localname, 'rb')
     data = f.read()
     f.close()
 
     try:
-      retVal = self.verifySendCMD(['push ' + destname + ' ' + str(filesize) + '\r\n', data], newline = False)
-    except(DMError):
+      retVal = self.runCmds(['push ' + destname + ' ' + str(filesize) + '\r\n', data], newline = False)
+    except AgentError:
       retVal = False
   
     if (self.debug >= 3): print "push returned: " + str(retVal)
 
     validated = False
     if (retVal):
       retline = retVal.strip()
       if (retline == None):
@@ -318,18 +370,18 @@ class DeviceManagerSUT(DeviceManager):
   # returns:
   #  success: directory name
   #  failure: None
   def mkDir(self, name):
     if (self.dirExists(name)):
       return name
     else:
       try:
-        retVal = self.verifySendCMD(['mkdr ' + name])
-      except(DMError):
+        retVal = self.runCmds(['mkdr ' + name])
+      except AgentError:
         retVal = None
       return retVal
 
   # make directory structure on the device
   # external function
   # returns:
   #  success: directory structure that we created
   #  failure: None
@@ -371,18 +423,18 @@ class DeviceManagerSUT(DeviceManager):
   # external function
   # returns:
   #  success: True
   #  failure: False
   def dirExists(self, dirname):
     match = ".*" + dirname + "$"
     dirre = re.compile(match)
     try:
-      data = self.verifySendCMD(['cd ' + dirname, 'cwd'])
-    except(DMError):
+      data = self.runCmds(['cd ' + dirname, 'cwd'])
+    except AgentError:
       return False
 
     found = False
     for d in data.splitlines():
       if (dirre.match(d)):
         found = True
 
     return found
@@ -407,107 +459,109 @@ class DeviceManagerSUT(DeviceManager):
   # returns:
   #  success: array of filenames, ['file1', 'file2', ...]
   #  failure: []
   def listFiles(self, rootdir):
     rootdir = rootdir.rstrip('/')
     if (self.dirExists(rootdir) == False):
       return []
     try:
-      data = self.verifySendCMD(['cd ' + rootdir, 'ls'])
-    except(DMError):
+      data = self.runCmds(['cd ' + rootdir, 'ls'])
+    except AgentError:
       return []
 
     files = filter(lambda x: x, data.splitlines())
     if len(files) == 1 and files[0] == '<empty>':
       # special case on the agent: empty directories return just the string "<empty>"
       return []
     return files
 
   # external function
   # returns:
   #  success: output of telnet, i.e. "removing file: /mnt/sdcard/tests/test.txt"
   #  failure: None
   def removeFile(self, filename):
     if (self.debug>= 2): print "removing file: " + filename
     try:
-      retVal = self.verifySendCMD(['rm ' + filename])
-    except(DMError):
+      retVal = self.runCmds(['rm ' + filename])
+    except AgentError:
       return None
 
     return retVal
   
   # does a recursive delete of directory on the device: rm -Rf remoteDir
   # external function
   # returns:
   #  success: output of telnet, i.e. "removing file: /mnt/sdcard/tests/test.txt"
   #  failure: None
   def removeDir(self, remoteDir):
     try:
-      retVal = self.verifySendCMD(['rmdr ' + remoteDir])
-    except(DMError):
+      retVal = self.runCmds(['rmdr ' + remoteDir])
+    except AgentError:
       return None
 
     return retVal
 
   # external function
   # returns:
   #  success: array of process tuples
   #  failure: []
   def getProcessList(self):
     try:
-      data = self.verifySendCMD(['ps'])
-    except DMError:
+      data = self.runCmds(['ps'])
+    except AgentError:
       return []
 
     files = []
     for line in data.splitlines():
       if line:
         pidproc = line.strip().split()
         if (len(pidproc) == 2):
           files += [[pidproc[0], pidproc[1]]]
         elif (len(pidproc) == 3):
           #android returns <userID> <procID> <procName>
           files += [[pidproc[1], pidproc[2], pidproc[0]]]     
     return files
 
   # external function
+  # DEPRECATED: Use shell() or launchApplication() for new code
   # returns:
   #  success: pid
   #  failure: None
   def fireProcess(self, appname, failIfRunning=False):
     if (not appname):
       if (self.debug >= 1): print "WARNING: fireProcess called with no command to run"
       return None
 
     if (self.debug >= 2): print "FIRE PROC: '" + appname + "'"
 
     if (self.processExist(appname) != None):
       print "WARNING: process %s appears to be running already\n" % appname
       if (failIfRunning):
         return None
     
     try:
-      data = self.verifySendCMD(['exec ' + appname])
-    except(DMError):
+      data = self.runCmds(['exec ' + appname])
+    except AgentError:
       return None
 
     # wait up to 30 seconds for process to start up
     timeslept = 0
     while (timeslept <= 30):
       process = self.processExist(appname)
       if (process is not None):
         break
       time.sleep(3)
       timeslept += 3
 
     if (self.debug >= 4): print "got pid: %s for process: %s" % (process, appname)
     return process
 
   # external function
+  # DEPRECATED: Use shell() or launchApplication() for new code
   # returns:
   #  success: output filename
   #  failure: None
   def launchProcess(self, cmd, outputFile = "process.txt", cwd = '', env = '', failIfRunning=False):
     if not cmd:
       if (self.debug >= 1): print "WARNING: launchProcess called without command to run"
       return None
 
@@ -527,46 +581,46 @@ class DeviceManagerSUT(DeviceManager):
     return outputFile
 
   # external function
   # returns:
   #  success: output from testagent
   #  failure: None
   def killProcess(self, appname):
     try:
-      data = self.verifySendCMD(['kill ' + appname])
-    except(DMError):
+      data = self.runCmds(['kill ' + appname])
+    except AgentError:
       return None
 
     return data
 
   # external function
   # returns:
   #  success: tmpdir, string
   #  failure: None
   def getTempDir(self):
     try:
-      data = self.verifySendCMD(['tmpd'])
-    except(DMError):
+      data = self.runCmds(['tmpd'])
+    except AgentError:
       return None
 
     return data.strip()
 
   # external function
   # returns:
   #  success: filecontents
   #  failure: None
   def catFile(self, remoteFile):
     try:
-      data = self.verifySendCMD(['cat ' + remoteFile])
-    except(DMError):
+      data = self.runCmds(['cat ' + remoteFile])
+    except AgentError:
       return None
 
     return data
-  
+
   # external function
   # returns:
   #  success: output of pullfile, string
   #  failure: None
   def pullFile(self, remoteFile):
     """Returns contents of remoteFile using the "pull" command.
     The "pull" command is different from other commands in that DeviceManager
     has to read a certain number of bytes instead of just reading to the
@@ -620,18 +674,18 @@ class DeviceManagerSUT(DeviceManager):
     prompt = self.base_prompt + self.prompt_sep
     buffer = ''
     
     # expected return value:
     # <filename>,<filesize>\n<filedata>
     # or, if error,
     # <filename>,-1\n<error message>
     try:
-      data = self.verifySendCMD(['pull ' + remoteFile])
-    except(DMError):
+      data = self.runCmds(['pull ' + remoteFile])
+    except AgentError:
       return None
 
     # read metadata; buffer the rest
     metadata, sep, buffer = read_until_char('\n', buffer, 'could not find metadata')
     if not metadata:
       return None
     if self.debug >= 3:
       print 'metadata: %s' % metadata
@@ -739,18 +793,18 @@ class DeviceManagerSUT(DeviceManager):
 
   # external function
   # returns:
   #  success: True
   #  failure: False
   #  Throws a FileError exception when null (invalid dir/filename)
   def isDir(self, remotePath):
     try:
-      data = self.verifySendCMD(['isdir ' + remotePath])
-    except(DMError):
+      data = self.runCmds(['isdir ' + remotePath])
+    except AgentError:
       # normally there should be no error here; a nonexistent file/directory will
       # return the string "<filename>: No such file or directory".
       # However, I've seen AGENT-WARNING returned before. 
       return False
 
     retVal = data.strip()
     if not retVal:
       raise FileError('isdir returned null')
@@ -775,18 +829,18 @@ class DeviceManagerSUT(DeviceManager):
   
   # return the md5 sum of a remote file
   # internal function
   # returns:
   #  success: MD5 hash for given filename
   #  failure: None
   def getRemoteHash(self, filename):
     try:
-      data = self.verifySendCMD(['hash ' + filename])
-    except(DMError):
+      data = self.runCmds(['hash ' + filename])
+    except AgentError:
       return None
 
     retVal = None
     if data:
       retVal = data.strip()
     if (self.debug >= 3): print "remote hash returned: '" + retVal + "'"
     return retVal
     
@@ -804,31 +858,31 @@ class DeviceManagerSUT(DeviceManager):
   #       /mochitest
   #
   # external function
   # returns:
   #  success: path for device root
   #  failure: None
   def getDeviceRoot(self):
     try:
-      data = self.verifySendCMD(['testroot'])
+      data = self.runCmds(['testroot'])
     except:
       return None
 
     deviceRoot = data.strip() + '/tests'
 
     if (not self.dirExists(deviceRoot)):
       if (self.mkDir(deviceRoot) == None):
         return None
 
     return deviceRoot
 
   def getAppRoot(self, packageName):
     try:
-      data = self.verifySendCMD(['getapproot '+packageName])
+      data = self.runCmds(['getapproot '+packageName])
     except:
       return None
 
     return data.strip()
 
   # external function
   # returns:
   #  success: output of unzip command
@@ -846,18 +900,18 @@ class DeviceManagerSUT(DeviceManager):
     elif self.fileExists('/' + filename):
       dir = '/' + filename
     elif self.fileExists(devroot + '/' + filename):
       dir = devroot + '/' + filename
     else:
       return None
 
     try:
-      data = self.verifySendCMD(['cd ' + dir, 'unzp ' + filename])
-    except(DMError):
+      data = self.runCmds(['cd ' + dir, 'unzp ' + filename])
+    except AgentError:
       return None
 
     return data
 
   # external function
   # returns:
   #  success: status from test agent
   #  failure: None
@@ -867,28 +921,28 @@ class DeviceManagerSUT(DeviceManager):
     if (self.debug > 3): print "INFO: sending rebt command"
     callbacksvrstatus = None    
 
     if (ipAddr is not None):
     #create update.info file:
       try:
         destname = '/data/data/com.mozilla.SUTAgentAndroid/files/update.info'
         data = "%s,%s\rrebooting\r" % (ipAddr, port)
-        self.verifySendCMD(['push ' + destname + ' ' + str(len(data)) + '\r\n', data], newline = False)
-      except(DMError):
+        self.runCmds(['push ' + destname + ' ' + str(len(data)) + '\r\n', data], newline = False)
+      except AgentError:
         return None
 
       ip, port = self.getCallbackIpAndPort(ipAddr, port)
       cmd += " %s %s" % (ip, port)
       # Set up our callback server
       callbacksvr = callbackServer(ip, port, self.debug)
 
     try:
-      status = self.verifySendCMD([cmd])
-    except(DMError):
+      status = self.runCmds([cmd])
+    except AgentError:
       return None
 
     if (ipAddr is not None):
       status = callbacksvr.disconnect()
 
     if (self.debug > 3): print "INFO: rebt- got status back: " + str(status)
     return status
 
@@ -913,17 +967,17 @@ class DeviceManagerSUT(DeviceManager):
     collapseSpaces = re.compile('  +')
 
     directives = ['os', 'id','uptime','systime','screen','memory','process',
                   'disk','power']
     if (directive in directives):
       directives = [directive]
 
     for d in directives:
-      data = self.verifySendCMD(['info ' + d])
+      data = self.runCmds(['info ' + d])
       if (data is None):
         continue
       data = collapseSpaces.sub(' ', data)
       result[d] = data.split('\n')
 
     # Get rid of any 0 length members of the arrays
     for k, v in result.iteritems():
       result[k] = filter(lambda x: x != '', result[k])
@@ -950,18 +1004,18 @@ class DeviceManagerSUT(DeviceManager):
   # returns:
   #  success: output from agent for inst command
   #  failure: None
   def installApp(self, appBundlePath, destPath=None):
     cmd = 'inst ' + appBundlePath
     if destPath:
       cmd += ' ' + destPath
     try:
-      data = self.verifySendCMD([cmd])
-    except(DMError):
+      data = self.runCmds([cmd])
+    except AgentError:
       return None
 
     f = re.compile('Failure')
     for line in data.split():
       if (f.match(line)):
         return data
     return None
 
@@ -976,18 +1030,18 @@ class DeviceManagerSUT(DeviceManager):
   # returns:
   #  success: True
   #  failure: None
   def uninstallAppAndReboot(self, appName, installPath=None):
     cmd = 'uninst ' + appName
     if installPath:
       cmd += ' ' + installPath
     try:
-      data = self.verifySendCMD([cmd])
-    except(DMError):
+      data = self.runCmds([cmd])
+    except AgentError:
       return None
 
     if (self.debug > 3): print "uninstallAppAndReboot: " + str(data)
     return True
 
   """
   Updates the application on the device.
   Application bundle - path to the application bundle on the device
@@ -1021,18 +1075,18 @@ class DeviceManagerSUT(DeviceManager):
       ip, port = self.getCallbackIpAndPort(ipAddr, port)
       cmd += " %s %s" % (ip, port)
       # Set up our callback server
       callbacksvr = callbackServer(ip, port, self.debug)
 
     if (self.debug >= 3): print "INFO: updateApp using command: " + str(cmd)
 
     try:
-      status = self.verifySendCMD([cmd])
-    except(DMError):
+      status = self.runCmds([cmd])
+    except AgentError:
       return None
 
     if ipAddr is not None:
       status = callbacksvr.disconnect()
 
     if (self.debug >= 3): print "INFO: updateApp: got status back: " + str(status)
 
     return status
@@ -1041,18 +1095,18 @@ class DeviceManagerSUT(DeviceManager):
     return the current time on the device
   """
   # external function
   # returns:
   #  success: time in ms
   #  failure: None
   def getCurrentTime(self):
     try:
-      data = self.verifySendCMD(['clok'])
-    except(DMError):
+      data = self.runCmds(['clok'])
+    except AgentError:
       return None
 
     return data.strip()
 
   """
     Connect the ipaddress and port for a callback ping.  Defaults to current IP address
     And ports starting at 30000.
     NOTE: the detection for current IP address only works on Linux!
@@ -1074,18 +1128,18 @@ class DeviceManagerSUT(DeviceManager):
     elif self.fileExists('/' + filename):
       dir = '/' + filename
     elif self.fileExists(devroot + '/' + filename):
       dir = devroot + '/' + filename
     else:
       return None
 
     try:
-      data = self.verifySendCMD(['cd ' + dir, 'unzp ' + filename])
-    except(DMError):
+      data = self.runCmds(['cd ' + dir, 'unzp ' + filename])
+    except AgentError:
       return None
 
     return data
 
   def getCallbackIpAndPort(self, aIp, aPort):
     ip = aIp
     nettools = NetworkTools()
     if (ip == None):
@@ -1145,19 +1199,19 @@ class DeviceManagerSUT(DeviceManager):
     if (width < 100 or width > 9999):
       return False
 
     if (height < 100 or height > 9999):
       return False
 
     if (self.debug >= 3): print "INFO: adjusting screen resolution to %s, %s and rebooting" % (width, height)
     try:
-      self.verifySendCMD(["exec setprop persist.tegra.dpy%s.mode.width %s" % (screentype, width)])
-      self.verifySendCMD(["exec setprop persist.tegra.dpy%s.mode.height %s" % (screentype, height)])
-    except(DMError):
+      self.runCmds(["exec setprop persist.tegra.dpy%s.mode.width %s" % (screentype, width)])
+      self.runCmds(["exec setprop persist.tegra.dpy%s.mode.height %s" % (screentype, height)])
+    except AgentError:
       return False
 
     return True
 
 gCallbackData = ''
 
 class myServer(SocketServer.TCPServer):
   allow_reuse_address = True
new file mode 100644
--- /dev/null
+++ b/build/mobile/droid.py
@@ -0,0 +1,87 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is Test Automation Framework.
+#
+# The Initial Developer of the Original Code is
+# Mozilla foundation
+#
+# Portions created by the Initial Developer are Copyright (C) 2009
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#   Joel Maher <joel.maher@gmail.com> (Original Developer)
+#   William Lachance <wlachance@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+from devicemanagerADB import DeviceManagerADB
+from devicemanagerSUT import DeviceManagerSUT
+
+class DroidMixin(object):
+  """Mixin to extend DeviceManager with Android-specific functionality"""
+
+  def launchApplication(self, app, activity="App",
+                        intent="android.intent.action.VIEW", env=None,
+                        url=None, extra_args=None):
+    """
+    Launches an Android application
+    returns:
+    success: True
+    failure: False
+    """
+    # only one instance of an application may be running at once
+    if self.processExist(app):
+      return False
+
+    acmd = [ "am", "start", "-a", intent, "-W", "-n", "%s/.%s" % (app, activity)]
+
+    if extra_args:
+      acmd.extend(["--es", "args", " ".join(args)])
+
+    if env:
+      envCnt = 0
+      # env is expected to be a dict of environment variables
+      for envkey, envval in env.iteritems():
+        acmd.extend(["--es", "env" + str(envCnt), envkey + "=" + envval])
+        envCnt += 1
+
+    if url:
+      acmd.extend(["-d", ''.join(['"', url, '"'])])
+
+    # shell output not that interesting and debugging logs should already
+    # show what's going on here... so just create an empty memory buffer
+    # and ignore
+    shellOutput = StringIO.StringIO()
+    if self.shell(acmd, shellOutput) == 0:
+      return True
+
+    return False
+
+class DroidADB(DeviceManagerADB, DroidMixin):
+  pass
+
+class DroidSUT(DeviceManagerSUT, DroidMixin):
+  pass
--- a/build/mobile/sutagent/android/DoCommand.java
+++ b/build/mobile/sutagent/android/DoCommand.java
@@ -131,22 +131,23 @@ public class DoCommand {
 
     String    currentDir = "/";
     String    sErrorPrefix = "##AGENT-WARNING## ";
     boolean bTraceOn = false;
 
     String ffxProvider = "org.mozilla.ffxcp";
     String fenProvider = "org.mozilla.fencp";
 
-    private final String prgVersion = "SUTAgentAndroid Version 1.05";
+    private final String prgVersion = "SUTAgentAndroid Version 1.06";
 
     public enum Command
         {
         RUN ("run"),
         EXEC ("exec"),
+        EXECCWD ("execcwd"),
         ENVRUN ("envrun"),
         KILL ("kill"),
         PS ("ps"),
         DEVINFO ("info"),
         OS ("os"),
         ID ("id"),
         UPTIME ("uptime"),
         SETTIME ("settime"),
@@ -687,17 +688,35 @@ public class DoCommand {
                     {
                     String [] theArgs = new String [Argc - 1];
 
                     for (int lcv = 1; lcv < Argc; lcv++)
                         {
                         theArgs[lcv - 1] = Argv[lcv];
                         }
 
-                    strReturn = StartPrg2(theArgs, cmdOut);
+                    strReturn = StartPrg2(theArgs, cmdOut, null);
+                    }
+                else
+                    {
+                    strReturn = sErrorPrefix + "Wrong number of arguments for " + Argv[0] + " command!";
+                    }
+                break;
+
+            case EXECCWD:
+                if (Argc >= 3)
+                    {
+                    String [] theArgs = new String [Argc - 2];
+
+                    for (int lcv = 2; lcv < Argc; lcv++)
+                        {
+                        theArgs[lcv - 2] = Argv[lcv];
+                        }
+
+                    strReturn = StartPrg2(theArgs, cmdOut, Argv[1]);
                     }
                 else
                     {
                     strReturn = sErrorPrefix + "Wrong number of arguments for " + Argv[0] + " command!";
                     }
                 break;
 
             case RUN:
@@ -1257,16 +1276,21 @@ private void CancelNotification()
 
         return(sRet);
         }
 
     public String GetTestRoot()
         {
         String    sRet = null;
 
+        File tmpFile = new java.io.File("/data/local/tests");
+        if (tmpFile.exists() && tmpFile.isDirectory()) 
+            {
+            return("/data/local");
+            }
         if (Environment.getExternalStorageState().equalsIgnoreCase(Environment.MEDIA_MOUNTED))
             {
             sRet = Environment.getExternalStorageDirectory().getAbsolutePath();
             }
         else
             {
             sRet = GetTmpDir();
             }
@@ -3458,17 +3482,17 @@ private void CancelNotification()
             {
             e.printStackTrace();
             sRet = "Timed out!";
             }
 
         return (sRet);
         }
 
-    public String StartPrg2(String [] progArray, OutputStream out)
+    public String StartPrg2(String [] progArray, OutputStream out, String cwd)
         {
         String sRet = "";
 
         int    nArraySize = 0;
         int    nArgs = progArray.length - 1; // 1st arg is the environment string
         int    lcv    = 0;
         int    temp = 0;
 
@@ -3548,17 +3572,25 @@ private void CancelNotification()
 
             for (Map.Entry<String, String> entry : newEnv.entrySet())
                 {
                 envArray[i++] = entry.getKey() + "=" + entry.getValue();
                 }
 
             if (theArgs[0].contains("/") || theArgs[0].contains("\\") || !theArgs[0].contains("."))
                 {
-                pProc = Runtime.getRuntime().exec(theArgs, envArray);
+                if (cwd != null)
+                    {
+                    File f = new File(cwd);
+                    pProc = Runtime.getRuntime().exec(theArgs, envArray, f);
+                    }
+                else
+                    {
+                    pProc = Runtime.getRuntime().exec(theArgs, envArray);
+                    }
 
                 RedirOutputThread outThrd = new RedirOutputThread(pProc, out);
                 outThrd.start();
 
                 lcv = 0;
 
                 while (lcv < 30) {
                     try {