apkcrawler: Store view hierarchy and fix clicking.

Store the view hierarchy and fragment list in a JSON file. Also, improve
clicking by using the right height/width parameters and converting them
from unicode to int.

Change-Id: If08574181faaa711f1b921414e1c6c75fc07ade6
diff --git a/crawlui.py b/apkcrawler/crawlui.py
similarity index 62%
rename from crawlui.py
rename to apkcrawler/crawlui.py
index 4d9b5b1..4d94719 100644
--- a/crawlui.py
+++ b/apkcrawler/crawlui.py
@@ -1,5 +1,7 @@
 """A module for installing and crawling the UI of Android application."""
 
+import copy
+import json
 import os
 import re
 import subprocess
@@ -7,15 +9,16 @@
 from view import View
 
 # Linux ADB path
-# ADB_PATH = os.path.expanduser('~') + '/Android/Sdk/platform-tools/adb'
+ADB_PATH = os.path.expanduser('~') + '/Android/Sdk/platform-tools/adb'
 # OS X ADB path
-ADB_PATH = '/usr/local/bin/adb'
+# ADB_PATH = '/usr/local/bin/adb'
 
-MAX_WIDTH = 1080
+# Nexus 6 dimensions.
+MAX_WIDTH = 1440
 # TODO(afergan): For layouts longer than the width of the screen, scroll down
 # and click on them. For now, we ignore them.
-MAX_HEIGHT = 1920
-NAVBAR_HEIGHT = 63
+MAX_HEIGHT = 2560
+NAVBAR_HEIGHT = 84
 
 # Visibility
 VISIBLE = 0x0
@@ -26,9 +29,6 @@
 # activities cannot have spaces, we ensure that no activity will be named this.
 EXITED_APP = 'exited app'
 
-view_root = []
-view_array = []
-
 
 def perform_press_back():
   subprocess.call([ADB_PATH, 'shell', 'input', 'keyevent', '4'])
@@ -79,70 +79,74 @@
   return frag_list
 
 
-def save_screenshot(package_name, activity, frag_list):
+def save_view_data(package_name, activity, frag_list, vc_dump):
   """Store the screenshot with a unique filename."""
   directory = (os.path.dirname(os.path.abspath(__file__)) + '/data/'
                + package_name)
   if not os.path.exists(directory):
     os.makedirs(directory)
-  screenshot_num = 0
-  while os.path.exists(directory + '/' + activity + '-' + frag_list[0] + '-' +
-                       str(screenshot_num) + '.png'):
-    screenshot_num += 1
-  screen_name = activity + '-' + frag_list[0] + '-' + str(screenshot_num) + '.png'
-  print screen_name
-  screen_path = directory + '/' + screen_name
+  file_num = 0
+  dump_file = os.path.join(directory, activity + '-' + frag_list[0] + '-'
+                           + str(file_num) + '.json')
+  while os.path.exists(dump_file):
+    file_num += 1
+    dump_file = os.path.join(directory, activity + '-' + frag_list[0] + '-'
+                             + str(file_num) + '.json')
+
+  view_info = {}
+  view_info['hierarchy'] = {}
+  view_info['fragmentList'] = frag_list
+
+  for component in vc_dump:
+    # Because the children and parent are each instances, they are not JSON
+    # serializable. We replace them with just the ids of the instances (and
+    # discard the device info).
+    dict_copy = copy.copy(component.__dict__)
+    del dict_copy['device']
+    if dict_copy['parent']:
+      dict_copy['parent'] = dict_copy['parent']['uniqueId']
+    dict_copy['children'] = []
+    for child in component.__dict__['children']:
+      dict_copy['children'].append(child['uniqueId'])
+    view_info['hierarchy'][component['uniqueId']] = dict_copy
+
+  with open(dump_file, 'w') as out_file:
+    json.dump(view_info, out_file, indent=2)
+
+  screen_name = (activity + '-' + frag_list[0] + '-' + str(file_num) + '.png')
+  screen_path = os.path.join(directory, screen_name)
   subprocess.call([ADB_PATH, 'shell', 'screencap', '/sdcard/' + screen_name])
   subprocess.call([ADB_PATH, 'pull', '/sdcard/' + screen_name, screen_path])
 
   # Return the filename & num so that the screenshot can be accessed
   # programatically.
-  return [screen_path, screenshot_num]
+  return [screen_path, file_num]
 
 
-def find_view_idx(vc_dump, activity, frag_list):
+def find_view_idx(vc_dump, activity, frag_list, view_array):
   """Find the index of the current View in the view array (-1 if new view)."""
   for i in range(len(view_array)):
-    if view_array[i].is_duplicate(activity,
-                                  frag_list, vc_dump):
+    if view_array[i].is_duplicate(activity, frag_list, vc_dump):
       return i
   return -1
 
 
-def create_view(package_name, vc_dump, activity, frag_list, debug):
+def create_view(package_name, vc_dump, activity, frag_list):
   """Store the current view in the View data structure."""
-  screenshot_info = save_screenshot(package_name, activity, frag_list)
-  v = View(activity, frag_list, vc_dump,screenshot_info[0], screenshot_info[1])
+  screenshot_info = save_view_data(package_name, activity, frag_list, vc_dump)
+  v = View(activity, frag_list, vc_dump, screenshot_info[0], screenshot_info[1])
 
   for component in v.hierarchy:
     # TODO(afergan): For now, only click on certain components, and allow custom
     # components. Evaluate later if this is worth it or if we should just click
     # on everything attributed as clickable.
 
-    # TODO(afergan): Should we include clickable ImageViews? Seems like a lot of
-    # false clicks for this so far...
-    if component.isClickable():
-      print (component['class'] + ' ' + str(component.getX()) + ' '
-             + str(component.getY()))
     if (component.isClickable() and component.getVisibility() == VISIBLE and
-        component.getX() >= 0 and component.getY() <= MAX_WIDTH and
-        component['layout:layout_width'] > 0 and
+        component.getX() >= 0 and component.getX() <= MAX_WIDTH and
+        int(component['layout:getWidth()']) > 0 and
         component.getY() >= (NAVBAR_HEIGHT) and
         component.getY() <= MAX_HEIGHT and
-        component['layout:layout_height'] > 0 and
-        ('Button' in component['class'] or
-         'TextView' in component['class'] or
-         'ActionMenuItemView' in component['class'] or
-         'Spinner' in component['class'] or
-         (component['class'] ==
-          'com.android.settings.dashboard.DashboardTileView')  or
-         component['class'] == 'android.widget.ImageView' or
-         component.parent == 'android.widget.ListView'
-         'android' not in component['class']) and
-         # TODO(afergan): Remove this.
-         # For debugging purposes, get rid of the phantom clickable components
-         # that Zagat creates.
-         not (debug and component.getXY() == (0, 273))):
+        int(component['layout:getHeight()']) > 0):
       print component['class'] + '-- will be clicked'
       v.clickable.append(component)
 
@@ -152,6 +156,8 @@
 def crawl_package(apk_dir, package_name, vc, device, debug):
   """Main crawler loop. Evaluate views, store new views, and click on items."""
 
+  view_root = []
+  view_array = []
   if not debug:
     # Install the app.
     subprocess.call([ADB_PATH, 'install', '-r', apk_dir + package_name
@@ -167,37 +173,39 @@
   if activity == EXITED_APP:
     return
   frag_list = get_frag_list(package_name)
-  global view_root
-  view_root = create_view(package_name, vc_dump, activity,
-                          frag_list, debug)
+  view_root = create_view(package_name, vc_dump, activity, frag_list)
   view_array.append(view_root)
+  curr_view = view_root
 
   while True:
 
+    # If last click opened the keyboard, assume we're in the same view and just
+    # click on the next element. Since opening the keyboard can leave traces of
+    # additional components, don't check if view is duplicate.
+    # TODO(afergan): Is this a safe assumption?
     if device.isKeyboardShown():
       perform_press_back()
-
-    # Determine if this is a View that has already been seen.
-    view_idx = find_view_idx(vc_dump, activity, frag_list)
-    if view_idx >= 0:
-      print '**FOUND DUPLICATE'
-      curr_view = view_array[view_idx]
     else:
-      print '**NEW VIEW'
-      curr_view = create_view(package_name, vc_dump, activity, frag_list, debug)
-      view_array.append(curr_view)
+      # Determine if this is a View that has already been seen.
+      view_idx = find_view_idx(vc_dump, activity, frag_list, view_array)
+      if view_idx >= 0:
+        print '**FOUND DUPLICATE'
+        curr_view = view_array[view_idx]
+      else:
+        print '**NEW VIEW'
+        curr_view = create_view(package_name, vc_dump, activity, frag_list)
+        view_array.append(curr_view)
 
     print 'Num clickable: ' + str(len(curr_view.clickable))
 
     if curr_view.clickable:
-      c = curr_view.clickable[0]
-      print c
-      print ('Clickable: ' + c['uniqueId'] + ' ' + c['class'] + ' ' +
-             str(c.getX()) + ' ' + str(c.getY()))
+      c = curr_view.clickable[-1]
+      print ('Clickable: {} {}, ({},{})'.format(c['uniqueId'], c['class'],
+                                                c.getX(), c.getY()))
       subprocess.call([ADB_PATH, 'shell', 'input', 'tap', str(c.getX()),
                        str(c.getY())])
       print str(len(curr_view.clickable)) + ' elements left to click'
-      del curr_view.clickable[0]
+      del curr_view.clickable[-1]
 
     else:
       print '!!! Clicking back button'
@@ -210,4 +218,3 @@
     if activity == EXITED_APP:
       return
     frag_list = get_frag_list(package_name)
-
diff --git a/main.py b/apkcrawler/main.py
similarity index 100%
rename from main.py
rename to apkcrawler/main.py
diff --git a/view.py b/apkcrawler/view.py
similarity index 95%
rename from view.py
rename to apkcrawler/view.py
index 6863b63..965e0e2 100644
--- a/view.py
+++ b/apkcrawler/view.py
@@ -30,8 +30,8 @@
   def is_duplicate(self, cv_activity, cv_frag_list, cv_hierarchy):
     """Determine if the passed-in current view is identical to this View."""
 
-    # Since the fragment names are hashable, this is the most efficient method to
-    # compare two unordered lists according to
+    # Since the fragment names are hashable, this is the most efficient method
+    # to compare two unordered lists according to
     # http://stackoverflow.com/questions/7828867/how-to-efficiently-compare-two-unordered-lists-not-sets-in-python
     # We also use it below to compare hierarchy ids.
     if (self.activity != cv_activity or