| # 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 StringIO |
| import moznetwork |
| import re |
| import threading |
| import time |
| |
| import version_codes |
| |
| from Zeroconf import Zeroconf, ServiceBrowser |
| from devicemanager import ZeroconfListener |
| from devicemanagerADB import DeviceManagerADB |
| from devicemanagerSUT import DeviceManagerSUT |
| from devicemanager import DMError |
| |
| class DroidMixin(object): |
| """Mixin to extend DeviceManager with Android-specific functionality""" |
| |
| _stopApplicationNeedsRoot = True |
| |
| def _getExtraAmStartArgs(self): |
| return [] |
| |
| def launchApplication(self, appName, activityName, intent, url=None, |
| extras=None, wait=True, failIfRunning=True): |
| """ |
| Launches an Android application |
| |
| :param appName: Name of application (e.g. `com.android.chrome`) |
| :param activityName: Name of activity to launch (e.g. `.Main`) |
| :param intent: Intent to launch application with |
| :param url: URL to open |
| :param extras: Dictionary of extra arguments to launch application with |
| :param wait: If True, wait for application to start before returning |
| :param failIfRunning: Raise an exception if instance of application is already running |
| """ |
| |
| # If failIfRunning is True, we throw an exception here. Only one |
| # instance of an application can be running at once on Android, |
| # starting a new instance may not be what we want depending on what |
| # we want to do |
| if failIfRunning and self.processExist(appName): |
| raise DMError("Only one instance of an application may be running " |
| "at once") |
| |
| acmd = [ "am", "start" ] + self._getExtraAmStartArgs() + \ |
| ["-W" if wait else '', "-n", "%s/%s" % (appName, activityName)] |
| |
| if intent: |
| acmd.extend(["-a", intent]) |
| |
| if extras: |
| for (key, val) in extras.iteritems(): |
| if type(val) is int: |
| extraTypeParam = "--ei" |
| elif type(val) is bool: |
| extraTypeParam = "--ez" |
| else: |
| extraTypeParam = "--es" |
| acmd.extend([extraTypeParam, str(key), str(val)]) |
| |
| if url: |
| acmd.extend(["-d", 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 (except on error) |
| shellOutput = StringIO.StringIO() |
| if self.shell(acmd, shellOutput) == 0: |
| return |
| |
| shellOutput.seek(0) |
| raise DMError("Unable to launch application (shell output: '%s')" % shellOutput.read()) |
| |
| def launchFennec(self, appName, intent="android.intent.action.VIEW", |
| mozEnv=None, extraArgs=None, url=None, wait=True, |
| failIfRunning=True): |
| """ |
| Convenience method to launch Fennec on Android with various debugging |
| arguments |
| |
| :param appName: Name of fennec application (e.g. `org.mozilla.fennec`) |
| :param intent: Intent to launch application with |
| :param mozEnv: Mozilla specific environment to pass into application |
| :param extraArgs: Extra arguments to be parsed by fennec |
| :param url: URL to open |
| :param wait: If True, wait for application to start before returning |
| :param failIfRunning: Raise an exception if instance of application is already running |
| """ |
| extras = {} |
| |
| if mozEnv: |
| # mozEnv is expected to be a dictionary of environment variables: Fennec |
| # itself will set them when launched |
| for (envCnt, (envkey, envval)) in enumerate(mozEnv.iteritems()): |
| extras["env" + str(envCnt)] = envkey + "=" + envval |
| |
| # Additional command line arguments that fennec will read and use (e.g. |
| # with a custom profile) |
| if extraArgs: |
| extras['args'] = " ".join(extraArgs) |
| |
| self.launchApplication(appName, ".App", intent, url=url, extras=extras, |
| wait=wait, failIfRunning=failIfRunning) |
| |
| def getInstalledApps(self): |
| """ |
| Lists applications installed on this Android device |
| |
| Returns a list of application names in the form [ 'org.mozilla.fennec', ... ] |
| """ |
| output = self.shellCheckOutput(["pm", "list", "packages", "-f"]) |
| apps = [] |
| for line in output.splitlines(): |
| # lines are of form: package:/system/app/qik-tmo.apk=com.qiktmobile.android |
| apps.append(line.split('=')[1]) |
| |
| return apps |
| |
| def stopApplication(self, appName): |
| """ |
| Stops the specified application |
| |
| For Android 3.0+, we use the "am force-stop" to do this, which is |
| reliable and does not require root. For earlier versions of Android, |
| we simply try to manually kill the processes started by the app |
| repeatedly until none is around any more. This is less reliable and |
| does require root. |
| |
| :param appName: Name of application (e.g. `com.android.chrome`) |
| """ |
| version = self.shellCheckOutput(["getprop", "ro.build.version.sdk"]) |
| if int(version) >= version_codes.HONEYCOMB: |
| self.shellCheckOutput([ "am", "force-stop", appName ], root=self._stopApplicationNeedsRoot) |
| else: |
| num_tries = 0 |
| max_tries = 5 |
| while self.processExist(appName): |
| if num_tries > max_tries: |
| raise DMError("Couldn't successfully kill %s after %s " |
| "tries" % (appName, max_tries)) |
| self.killProcess(appName) |
| num_tries += 1 |
| |
| # sleep for a short duration to make sure there are no |
| # additional processes in the process of being launched |
| # (this is not 100% guaranteed to work since it is inherently |
| # racey, but it's the best we can do) |
| time.sleep(1) |
| |
| class DroidADB(DeviceManagerADB, DroidMixin): |
| |
| _stopApplicationNeedsRoot = False |
| |
| def getTopActivity(self): |
| package = None |
| data = None |
| try: |
| data = self.shellCheckOutput(["dumpsys", "window", "windows"], timeout=self.short_timeout) |
| except: |
| # dumpsys seems to intermittently fail (seen on 4.3 emulator), producing |
| # no output. |
| return "" |
| # "dumpsys window windows" produces many lines of input. The top/foreground |
| # activity is indicated by something like: |
| # mFocusedApp=AppWindowToken{483e6db0 token=HistoryRecord{484dcad8 com.mozilla.SUTAgentAndroid/.SUTAgentAndroid}} |
| # or, on other devices: |
| # FocusedApplication: name='AppWindowToken{41a65340 token=ActivityRecord{418fbd68 org.mozilla.fennec_mozdev/.App}}', dispatchingTimeout=5000.000ms |
| # Extract this line, ending in the forward slash: |
| m = re.search('mFocusedApp(.+)/', data) |
| if not m: |
| m = re.search('FocusedApplication(.+)/', data) |
| if m: |
| line = m.group(0) |
| # Extract package name: string of non-whitespace ending in forward slash |
| m = re.search('(\S+)/$', line) |
| if m: |
| package = m.group(1) |
| if not package: |
| # On some Android 4.4 devices, when the home screen is displayed, |
| # dumpsys reports "mFocusedApp=null". Guard against this case and |
| # others where the focused app can not be determined by returning |
| # an empty string -- same as sutagent. |
| package = "" |
| return package |
| |
| def getAppRoot(self, packageName): |
| """ |
| Returns the root directory for the specified android application |
| """ |
| # relying on convention |
| return '/data/data/%s' % packageName |
| |
| class DroidSUT(DeviceManagerSUT, DroidMixin): |
| |
| def _getExtraAmStartArgs(self): |
| # in versions of android in jellybean and beyond, the agent may run as |
| # a different process than the one that started the app. In this case, |
| # we need to get back the original user serial number and then pass |
| # that to the 'am start' command line |
| if not hasattr(self, '_userSerial'): |
| infoDict = self.getInfo(directive="sutuserinfo") |
| if infoDict.get('sutuserinfo') and \ |
| len(infoDict['sutuserinfo']) > 0: |
| userSerialString = infoDict['sutuserinfo'][0] |
| # user serial always an integer, see: http://developer.android.com/reference/android/os/UserManager.html#getSerialNumberForUser%28android.os.UserHandle%29 |
| m = re.match('User Serial:([0-9]+)', userSerialString) |
| if m: |
| self._userSerial = m.group(1) |
| else: |
| self._userSerial = None |
| else: |
| self._userSerial = None |
| |
| if self._userSerial is not None: |
| return [ "--user", self._userSerial ] |
| |
| return [] |
| |
| def getTopActivity(self): |
| return self._runCmds([{ 'cmd': "activity" }]).strip() |
| |
| def getAppRoot(self, packageName): |
| return self._runCmds([{ 'cmd': 'getapproot %s' % packageName }]).strip() |
| |
| def DroidConnectByHWID(hwid, timeout=30, **kwargs): |
| """Try to connect to the given device by waiting for it to show up using mDNS with the given timeout.""" |
| zc = Zeroconf(moznetwork.get_ip()) |
| |
| evt = threading.Event() |
| listener = ZeroconfListener(hwid, evt) |
| sb = ServiceBrowser(zc, "_sutagent._tcp.local.", listener) |
| foundIP = None |
| if evt.wait(timeout): |
| # we found the hwid |
| foundIP = listener.ip |
| sb.cancel() |
| zc.close() |
| |
| if foundIP is not None: |
| return DroidSUT(foundIP, **kwargs) |
| print "Connected via SUT to %s [at %s]" % (hwid, foundIP) |
| |
| # try connecting via adb |
| try: |
| sut = DroidADB(deviceSerial=hwid, **kwargs) |
| except: |
| return None |
| |
| print "Connected via ADB to %s" % (hwid) |
| return sut |