1#!/usr/bin/env python
2# Copyright 2017 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7import fnmatch
8import logging
9import os
10import sys
11
12import telemetry_mini
13
14
15BROWSER_FLAGS = [
16    '--enable-remote-debugging',
17    '--disable-fre',
18    '--no-default-browser-check',
19    '--no-first-run',
20]
21
22TRACE_CONFIG = {
23    'excludedCategories': ['*'],
24    'includedCategories': ['rails', 'toplevel', 'startup', 'blink.user_timing'],
25    'memoryDumpConfig': {'triggers': []}
26}
27
28BROWSERS = {
29    'android-chrome': telemetry_mini.ChromeApp,
30    'android-chromium': telemetry_mini.ChromiumApp,
31    'android-system-chrome': telemetry_mini.SystemChromeApp,
32}
33
34
35class TwitterApp(telemetry_mini.AndroidApp):
36  PACKAGE_NAME = 'com.twitter.android'
37
38
39class InstagramApp(telemetry_mini.AndroidApp):
40  PACKAGE_NAME = 'com.instagram.android'
41
42
43class HangoutsApp(telemetry_mini.AndroidApp):
44  PACKAGE_NAME = 'com.google.android.talk'
45
46
47class TwitterFlipkartStory(telemetry_mini.UserStory):
48  """Load Chrome Custom Tab from another application.
49
50  The flow of the story is:
51  - Start Twitter app to view the @flipkart profile.
52  - Tap on a link to open Flipkart in a Chrome Custom Tab.
53  - Return to Twitter app.
54  """
55  NAME = 'twitter_flipkart'
56  FLIPKART_TWITTER_LINK = [
57      ('package', 'com.twitter.android'),
58      ('class', 'android.widget.TextView'),
59      ('text', 'flipkart.com')
60  ]
61
62  def __init__(self, *args, **kwargs):
63    super(TwitterFlipkartStory, self).__init__(*args, **kwargs)
64    self.watcher = ProcessWatcher(self.device)
65    self.twitter = TwitterApp(self.device)
66
67  def RunPrepareSteps(self):
68    self.twitter.ForceStop()
69
70  def RunStorySteps(self):
71    # Activity will launch Twitter app on Flipkart profile.
72    self.actions.StartActivity('https://twitter.com/flipkart')
73    self.watcher.StartWatching(self.twitter)
74
75    # Tapping on Flikpart link on Twitter app will launch Chrome.
76    self.actions.TapUiElement(self.FLIPKART_TWITTER_LINK)
77    self.watcher.StartWatching(self.browser)
78    self.browser.WaitForCurrentPageReady()
79    self.actions.SwipeUp(repeat=3)
80
81    # Return to Twitter app.
82    self.actions.GoBack()
83    self.watcher.AssertAllAlive()
84
85  def RunCleanupSteps(self):
86    self.twitter.ForceStop()
87
88
89class FlipkartInstagramStory(telemetry_mini.UserStory):
90  """Interaction between Chrome, PWAs and a WebView-based app.
91
92  The flow of the story is:
93  - Launch the Flipkart PWA.
94  - Go back home and launch the Instagram app.
95  - Use the app switcher to return to Flipkart.
96  - Go back home and launch Cricbuzz from a shortcut.
97  """
98  NAME = 'flipkart_instagram'
99
100  def __init__(self, *args, **kwargs):
101    super(FlipkartInstagramStory, self).__init__(*args, **kwargs)
102    self.watcher = ProcessWatcher(self.device)
103    self.instagram = InstagramApp(self.device)
104
105  def RunPrepareSteps(self):
106    self.instagram.ForceStop()
107    self.actions.ClearRecentApps()
108
109  def RunStorySteps(self):
110    # Tap on home screen shortcut to open Flipkart PWA.
111    self.actions.TapHomeScreenShortcut('Flipkart Lite')
112    self.watcher.StartWatching(self.browser)
113    self.browser.WaitForCurrentPageReady()
114    self.actions.SwipeUp(repeat=2)
115
116    # Go back home, then launch Instagram app.
117    self.actions.GoHome()
118    self.actions.TapHomeScreenShortcut('Instagram')
119    self.watcher.StartWatching(self.instagram)
120    self.actions.SwipeUp(repeat=5)
121
122    # Go to app switcher and return to Flipkart PWA.
123    self.actions.GoAppSwitcher()
124    self.actions.TapAppSwitcherTitle('Flipkart Lite')
125    self.actions.SwipeDown()
126
127    # Go back home, then open Cricbuzz shortcut.
128    self.actions.GoHome()
129    self.actions.TapHomeScreenShortcut('Cricbuzz')
130    self.browser.WaitForCurrentPageReady()
131    self.actions.SwipeUp()
132    self.watcher.AssertAllAlive()
133
134  def RunCleanupSteps(self):
135    self.instagram.ForceStop()
136
137
138class HangoutsIndiaTimesStory(telemetry_mini.UserStory):
139  """Interaction between Chrome and a non-WebView-based app.
140
141  TODO: Not sure if Hangouts is a non-WebView app. Consider using another app
142  if needed.
143
144  The flow of the story is:
145  - Launch the Hangouts app.
146  - Open a conversation with a link to an IndiaTimes article.
147  - Click on the link to launch Chrome.
148  - Go back to the conversation.
149  """
150  NAME = 'hangouts_indiatimes'
151  # TODO: Maybe use more specific targets, e.g. check url is in message.
152  FIRST_CONVERSATION = [
153      ('resource-id', 'com.google.android.talk:id/conversationContent'),
154      ('index', '0'),
155  ]
156  SECOND_MESSAGE = [
157      ('resource-id', 'com.google.android.talk:id/message_root'),
158      ('index', '1'),
159  ]
160
161  def __init__(self, *args, **kwargs):
162    super(HangoutsIndiaTimesStory, self).__init__(*args, **kwargs)
163    self.watcher = ProcessWatcher(self.device)
164    self.hangouts = HangoutsApp(self.device)
165
166  def RunPrepareSteps(self):
167    self.hangouts.ForceStop()
168
169  def RunStorySteps(self):
170    # Tap on home screen shortcut to open Hangouts app.
171    self.actions.TapHomeScreenShortcut('Hangouts')
172    self.watcher.StartWatching(self.hangouts)
173
174    # Find conversation with link to IndiaTimes and tap to launch Chrome.
175    self.actions.TapUiElement(self.FIRST_CONVERSATION)
176    self.actions.TapUiElement(self.SECOND_MESSAGE)
177    self.watcher.StartWatching(self.browser)
178    self.browser.WaitForCurrentPageReady()
179    self.actions.SwipeUp(repeat=4)
180
181    # Go back to Hangouts, then back Home.
182    self.actions.GoBack()
183    self.actions.Idle(2)
184    self.actions.GoHome()
185    self.watcher.AssertAllAlive()
186
187  def RunCleanupSteps(self):
188    self.hangouts.ForceStop()
189
190
191STORIES = (
192    TwitterFlipkartStory,
193    FlipkartInstagramStory,
194    HangoutsIndiaTimesStory,
195)
196
197
198class ProcessWatcher(object):
199  def __init__(self, device):
200    self.device = device
201    self._process_pid = {}
202
203  def StartWatching(self, process_name):
204    """Register a process or android app to keep track of its PID."""
205    if isinstance(process_name, telemetry_mini.AndroidApp):
206      process_name = process_name.PACKAGE_NAME
207
208    @telemetry_mini.RetryOn(returns_falsy=True)
209    def GetPids():
210      # Returns an empty list if the process name is not found.
211      return self.device.ProcessStatus()[process_name]
212
213    assert process_name not in self._process_pid
214    pids = GetPids()
215    assert pids, 'PID for %s not found' % process_name
216    assert len(pids) == 1, 'Single PID for %s expected, but found: %s' % (
217        process_name, pids)
218    logging.info('Started watching %s (PID=%d)', process_name, pids[0])
219    self._process_pid[process_name] = pids[0]
220
221  def AssertAllAlive(self):
222    """Check that all watched processes remain alive and were not restarted."""
223    status = self.device.ProcessStatus()
224    all_alive = True
225    for process_name, old_pid in sorted(self._process_pid.iteritems()):
226      new_pids = status[process_name]
227      if not new_pids:
228        all_alive = False
229        logging.error('Process %s died (PID=%d).', process_name, old_pid)
230      elif new_pids != [old_pid]:
231        all_alive = False
232        logging.error(
233            'Process %s restarted (PID=%d -> %s).', process_name,
234            old_pid, new_pids)
235      else:
236        logging.info('Process %s still alive (PID=%d)', process_name, old_pid)
237    assert all_alive, 'Some watched processes died or got restarted'
238
239
240def EnsureSingleBrowser(device, browser_name, force_install=False):
241  """Ensure a single Chrome browser is installed and available on the device.
242
243  Having more than one Chrome browser available may produce results which are
244  confusing or unreliable (e.g. unclear which browser will respond by default
245  to intents triggered by other apps).
246
247  This function ensures only the selected browser is available, installing it
248  if necessary, and uninstalling/disabling others.
249  """
250  browser = BROWSERS[browser_name](device)
251  available_browsers = set(device.ListPackages('chrome', only_enabled=True))
252
253  # Install or enable if needed.
254  if force_install or browser.PACKAGE_NAME not in available_browsers:
255    browser.Install()
256
257  # Uninstall disable other browser apps.
258  for other_browser in BROWSERS.itervalues():
259    if (other_browser.PACKAGE_NAME != browser.PACKAGE_NAME and
260        other_browser.PACKAGE_NAME in available_browsers):
261      other_browser(device).Uninstall()
262
263  # Finally check that only the selected browser is actually available.
264  available_browsers = device.ListPackages('chrome', only_enabled=True)
265  assert browser.PACKAGE_NAME in available_browsers, (
266      'Unable to make %s available' % browser.PACKAGE_NAME)
267  available_browsers.remove(browser.PACKAGE_NAME)
268  assert not available_browsers, (
269      'Other browsers may intefere with the test: %s' % available_browsers)
270  return browser
271
272
273def main():
274  browser_names = sorted(BROWSERS)
275  default_browser = 'android-chrome'
276  parser = argparse.ArgumentParser()
277  parser.add_argument('--serial',
278                      help='device serial on which to run user stories'
279                      ' (defaults to first device found)')
280  parser.add_argument('--adb-bin', default='adb', metavar='PATH',
281                      help='path to adb binary to use (default: %(default)s)')
282  parser.add_argument('--browser', default=default_browser, metavar='NAME',
283                      choices=browser_names,
284                      help='one of: %s' % ', '.join(
285                          '%s (default)' % b if b == default_browser else b
286                          for b in browser_names))
287  parser.add_argument('--story-filter', metavar='PATTERN', default='*',
288                      help='run the matching stories only (allows Unix'
289                      ' shell-style wildcards)')
290  parser.add_argument('--repeat', metavar='NUM', type=int, default=1,
291                      help='repeat the story set a number of times'
292                      ' (default: %(default)d)')
293  parser.add_argument('--output-dir', metavar='PATH',
294                      help='path to directory for placing output trace files'
295                      ' (defaults to current directory)')
296  parser.add_argument('--force-install', action='store_true',
297                      help='install APK even if browser is already available')
298  parser.add_argument('--apks-dir', metavar='PATH',
299                      help='path where to find APKs to install')
300  parser.add_argument('--port', type=int, default=1234,
301                      help='port for connection with device'
302                      ' (default: %(default)s)')
303  parser.add_argument('-v', '--verbose', action='store_true')
304  args = parser.parse_args()
305
306  logging.basicConfig()
307  if args.verbose:
308    logging.getLogger().setLevel(logging.INFO)
309
310  stories = [s for s in STORIES if fnmatch.fnmatch(s.NAME, args.story_filter)]
311  if not stories:
312    return 'No matching stories'
313
314  if args.output_dir is None:
315    args.output_dir = os.getcwd()
316  else:
317    args.output_dir = os.path.realpath(args.output_dir)
318  if not os.path.isdir(args.output_dir):
319    return 'Output directory does not exit'
320
321  if args.apks_dir is None:
322    args.apks_dir = os.path.realpath(os.path.join(
323        os.path.dirname(__file__), '..', '..', '..', '..',
324        'out', 'Release', 'apks'))
325  telemetry_mini.AndroidApp.APKS_DIR = args.apks_dir
326
327  telemetry_mini.AdbMini.ADB_BIN = args.adb_bin
328  if args.serial is None:
329    device = next(telemetry_mini.AdbMini.GetDevices())
330    logging.warning(
331        'Connected to first device found: --serial %s', device.serial)
332  else:
333    device = telemetry_mini.AdbMini(args.serial)
334
335  # Some operations may require a rooted device.
336  device.RunCommand('root')
337  device.RunCommand('wait-for-device')
338
339  browser = EnsureSingleBrowser(device, args.browser, args.force_install)
340  browser.SetBrowserFlags(BROWSER_FLAGS)
341  browser.SetTraceConfig(TRACE_CONFIG)
342  browser.SetDevToolsLocalPort(args.port)
343  telemetry_mini.RunStories(browser, stories, args.repeat, args.output_dir)
344
345
346if __name__ == '__main__':
347  sys.exit(main())
348