blob: 8e025f6ed5d6847a15a405c7a42c6db32a2190f2 [file] [log] [blame]
# Copyright 2016 The Vanadium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
"""A module for installing and crawling the UI of Android application."""
from collections import Counter
import copy
import json
import os
import re
import subprocess
import time
from com.dtmilano.android.common import obtainAdbPath
from view import View
MAX_HEIGHT = 0
MAX_WIDTH = 0
NAVBAR_HEIGHT = 0
# Visibility
VISIBLE = 0x0
INVISIBLE = 0x4
GONE = 0x8
ADB_PATH = obtainAdbPath()
BACK_BUTTON = 'back button'
# Return a unique string if the package is not the focused window. Since
# activities cannot have spaces, we ensure that no activity will be named this.
EXITED_APP = 'exited app'
FAILED_FINDING_NAME = 'failed finding name'
# How many times we should try pressing the back button to return to the app
# before giving up.
NUM_BACK_PRESSES = 3
# Number of dumps we'll try in a row before succumbing to socket timeouts and
# giving up.
MAX_DUMPS = 6
def extract_between(text, sub1, sub2, nth=1):
"""Extracts a substring from text between two given substrings."""
# Credit to
# https://www.daniweb.com/programming/software-development/code/446964/extract-a-string-between-2-substrings-python-
# Prevent sub2 from being ignored if it's not there.
if sub2 not in text.split(sub1, nth)[-1]:
return None
return text.split(sub1, nth)[-1].split(sub2, nth)[0]
def set_device_dimens(vc, device):
"""Sets global variables to the dimensions of the device."""
global MAX_HEIGHT, MAX_WIDTH, NAVBAR_HEIGHT
# Returns a string similar to "Physical size: 1440x2560"
size = device.shell('wm size')
MAX_HEIGHT = int(extract_between(size, 'x', '\r'))
MAX_WIDTH = int(extract_between(size, ': ', 'x'))
vc_dump = perform_vc_dump(vc)
if vc_dump:
NAVBAR_HEIGHT = (
vc_dump[0].getY() - int(vc_dump[0]['layout:getLocationOnScreen_y()']))
else:
# Keep navbar at default 0 height.
print 'Cannot get navbar height.'
def perform_press_back(device):
device.press('KEYCODE_BACK')
def perform_vc_dump(vc):
try:
return vc.dump(window='-1')
except IOError:
print '*** Socket timeout!'
return None
def return_to_app_activity(package_name, device):
"""Tries to press back a number of times to return to the app."""
# Returns the name of the activity, or EXITED_APP if it could not return.
for press_num in range(0, NUM_BACK_PRESSES):
perform_press_back(device)
activity = obtain_activity_name(package_name, device)
if activity != EXITED_APP:
print 'Returned to app'
return activity
time.sleep(5)
print 'Failed returning to app, attempt #' + str(press_num + 1)
return EXITED_APP
def obtain_activity_name(package_name, device):
"""Gets the current running activity of the package."""
# TODO(afergan): See if we can consolidate this with obtain_fragment_list, but
# still make sure that the current app has focus.
# TODO(afergan): Check for Windows compatibility.
activity_str = device.shell('dumpsys window windows '
'| grep -E \'mCurrentFocus\'')
# If a popup menu has captured the focus, the focus will be in the format
# mCurrentFocus=Window{8f1328e u0 PopupWindow:53a5957}
if 'PopupWindow' in activity_str:
popup_str = extract_between(activity_str, 'PopupWindow', '}')
return 'PopupWindow' + popup_str.replace(':', '')
if package_name in activity_str:
# The current focus returns a string in the format
# mCurrentFocus=Window{35f66c3 u0 com.google.zagat/com.google.android.apps.
# zagat.activities.BrowseListsActivity}
# We only want the text between the final period and the closing bracket.
return extract_between(activity_str, '.', '}', -1)
return EXITED_APP
def obtain_frag_list(package_name, device):
"""Gets the list of fragments in the current view."""
activity_dump = device.shell('dumpsys activity ' + package_name)
frag_dump = re.findall('Added Fragments:(.*?)FragmentManager', activity_dump,
re.DOTALL)
if frag_dump:
frag_list = re.findall(': (.*?){', frag_dump[0], re.DOTALL)
return frag_list
return None
def obtain_package_name(device):
"""Gets the package name of the current focused window."""
activity_str = device.shell('dumpsys window windows '
'| grep -E \'mCurrentFocus\'')
# The current focus returns a string in the format
# mCurrentFocus=Window{35f66c3 u0 com.google.zagat/com.google.android.apps.
# zagat.activities.BrowseListsActivity}
# We want the text before the backslash
pkg_name = extract_between(activity_str, ' ', '/', -1)
print 'Package name is ' + pkg_name
return pkg_name
def is_active_view(stored_view, package_name, device):
"""Check if the current View name matches a stored View."""
return (obtain_activity_name(package_name, device) == stored_view.activity
and Counter(obtain_frag_list(package_name, device)) ==
Counter(stored_view.frag_list))
def save_view_data(package_name, activity, frag_list, vc_dump):
"""Stores the view hierarchy and screenshots with unique filenames."""
# Returns the path to the screenshot and the file number.
if frag_list:
first_frag = frag_list[0]
else:
first_frag = 'NoFrags'
directory = (
os.path.dirname(os.path.abspath(__file__)) + '/data/' + package_name)
if not os.path.exists(directory):
os.makedirs(directory)
file_num = 0
dump_file = os.path.join(
directory, activity + '-' + first_frag + '-' + str(file_num) + '.json')
while os.path.exists(dump_file):
file_num += 1
dump_file = os.path.join(
directory,
activity + '-' + first_frag + '-' + 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'].getUniqueId()
dict_copy['children'] = []
for child in component.__dict__['children']:
dict_copy['children'].append(child.getUniqueId())
view_info['hierarchy'][component.getUniqueId()] = dict_copy
with open(dump_file, 'w') as out_file:
json.dump(view_info, out_file, indent=2)
screen_name = activity + '-' + first_frag + '-' + str(file_num) + '.png'
screen_path = os.path.join(directory, screen_name)
# device.shell() does not work for taking/pulling screencaps.
subprocess.call([ADB_PATH, 'shell', 'screencap', '/sdcard/' + screen_name])
subprocess.call([ADB_PATH, 'pull', '/sdcard/' + screen_name, screen_path])
# Returns the filename & num so that the screenshot can be accessed
# programatically.
return [screen_path, file_num]
def save_ui_flow_relationships(view_to_save, package_name):
"""Dumps to file the click dictionary and preceding Views."""
directory = (
os.path.dirname(os.path.abspath(__file__)) + '/data/' + package_name)
click_file = os.path.join(directory, view_to_save.get_name() + '-clicks.json')
click_info = {}
click_info['click_dict'] = view_to_save.click_dict
click_info['preceding'] = view_to_save.preceding
with open(click_file, 'w') as out_file:
json.dump(click_info, out_file, indent=2)
def find_view_in_map(activity, frag_list, vc_dump, view_map):
"""Finds the current View in the view array (empty if new View)."""
# TODO(afergan): Consider creating another map indexed by the values compared
# in is_duplicate so that this comparison is O(1).
for val in view_map.values():
if val.is_duplicate(activity, frag_list, vc_dump):
return val
return None
def create_view(package_name, vc_dump, activity, frag_list):
"""Stores the current view in the View data structure."""
save_info = save_view_data(package_name, activity, frag_list, vc_dump)
v = View(activity, frag_list, vc_dump, save_info[0], save_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.
if (component.isClickable() and component.getVisibility() == VISIBLE and
component.getX() >= 0 and component.getX() <= MAX_WIDTH and
component.getWidth() > 0 and
component.getY() >= NAVBAR_HEIGHT and component.getY() <= MAX_HEIGHT and
component.getHeight() > 0):
print (component.getId() + ' ' + component.getClass()
+ ' ' + str(component.getXY()) + '-- will be clicked')
v.clickable.append(component)
return v
def link_ui_views(prev_view, curr_view, prev_clicked, package_name):
"""Stores the relationship between prev_view and curr_view."""
# We store in the View information that the last view links to the current
# view, and that the current view can be reached from the last view. We use
# the id of the last clicked element as the dictionary key so that we know
# which element leads from view to view.
if prev_clicked:
print 'Previous clicked: ' + prev_clicked
prev_view.click_dict[prev_clicked] = curr_view.get_name()
curr_view.preceding.append(prev_view.get_name())
else:
print 'Lost track of last clicked!'
# TODO(afergan): Remove this later. For debugging, we print the clicks after
# each click to a new view is recorded. However, this results in a lot of
# repeated writes to the same file. In the future, we can just write each
# file once we're done crawling the app.
save_ui_flow_relationships(prev_view, package_name)
save_ui_flow_relationships(curr_view, package_name)
def obtain_curr_view(activity, package_name, vc_dump, view_map, still_exploring,
device):
"""Extracts UI info and return the current View."""
# Gets the current UI info. If we have seen this UI before, return the
# existing View. If not, create a new View and save it to the view array.
frag_list = obtain_frag_list(package_name, device)
view = find_view_in_map(activity, frag_list, vc_dump, view_map)
if view:
print 'Found duplicate'
return view
else:
print 'New view'
new_view = create_view(package_name, vc_dump, activity, frag_list)
view_map[new_view.get_name()] = new_view
if new_view.clickable:
still_exploring[new_view.get_name()] = new_view
print ('Added ' + new_view.get_name() + ' to still_exploring. Length is '
'now ' + str(len(still_exploring)))
return new_view
def find_component_to_lead_to_view(view1, view2):
"""Given 2 Views, return the component of view 1 that leads to view 2."""
try:
return view1.click_dict.keys()[view1.click_dict.values().index(
view2.get_name())]
except ValueError:
print '*** Could not find a component to link to the succeeding View!'
return FAILED_FINDING_NAME
def find_path_from_root_to_view(view, view_map):
"""Given a View, finds the path of UI elements to that View."""
path = []
curr_path_view = view
# If there is a splash screen or intro screen that is stored, we could have
# multiple Views that do not have preceding Views.
while curr_path_view.preceding:
print 'Looking for path from ' + view.get_name()
path_views = [p[0] for p in path]
succeeding_view = curr_path_view
# TODO(afergan): Using the first element in preceding doesn't ensure
# shortest path. Is it worth keeping track of the depth of every View to
# create the shortest path?
curr_path_view = None
for pre in succeeding_view.preceding:
if pre not in path_views:
curr_path_view = view_map.get(pre)
break
else:
return path
component = find_component_to_lead_to_view(curr_path_view, succeeding_view)
# This should not happen since if we store the predecessor of one View, we
# also store which component of the predecessor leads to that View. However,
# if it does, we can try exploring other preceding views
if component == FAILED_FINDING_NAME:
return []
else:
print ('Inserting ' + component + ', ' + curr_path_view.get_name()
+ ' to path')
path.insert(0, (curr_path_view.get_name(), component))
return path
def follow_path_to_view(path, goal, package_name, device, view_map,
still_exploring, vc):
"""Attempt to follow path all the way to the desired view."""
if not path:
return is_active_view(view_map.values()[0], package_name, device)
for p in path:
# We can be lenient here and only evaluate if the activity and fragments are
# the same (and allow the view hierarchy to have changed a little bit),
# since we then evaluate if the clickable component we want is in the View.
if not is_active_view(view_map.get(p[0]), package_name, device):
print 'Toto, I\'ve a feeling we\'re not on the right path anymore.'
p_idx = path.index(p)
if p_idx > 0:
activity = obtain_activity_name(package_name, device)
if activity is EXITED_APP:
return False
vc_dump = vc.dump(window='-1')
curr_view = obtain_curr_view(activity, package_name, vc_dump, view_map,
still_exploring, device)
prev_view = view_map.get(path[p_idx - 1][0])
prev_clicked = view_map.get(path[p_idx - 1][1])
link_ui_views(prev_view, curr_view, prev_clicked, package_name)
return False
click_id = p[1]
if click_id == BACK_BUTTON:
perform_press_back(device)
else:
vc_dump = perform_vc_dump(vc)
click_target = next((component for component in vc_dump
if component.getUniqueId() == click_id), None)
if click_target:
print 'Clicking on ' + click_target.getUniqueId()
click_target.touch()
else:
print ('Could not find the right component to click on, was looking '
'for ' + click_id)
return False
# Make sure that we end up at the View that we want.
return is_active_view(goal, package_name, device)
def crawl_until_exit(vc, device, package_name, view_map, still_exploring,
start_view):
"""Main crawler loop. Evaluates views, store new views, and click on items."""
curr_view = start_view
prev_clicked = ''
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(device)
activity = obtain_activity_name(package_name, device)
if activity is EXITED_APP:
activity = return_to_app_activity(package_name, device)
if activity is EXITED_APP:
print 'Current view is not app and we cannot return'
break
else:
prev_clicked = BACK_BUTTON
prev_view = curr_view
vc_dump = perform_vc_dump(vc)
if vc_dump:
curr_view = obtain_curr_view(activity, package_name, vc_dump, view_map,
still_exploring, device)
print 'Curr view: ' + curr_view.get_name()
if not prev_view.is_duplicate_view(curr_view):
print 'At a diff view!'
link_ui_views(prev_view, curr_view, prev_clicked, package_name)
print 'Num clickable: ' + str(len(curr_view.clickable))
if curr_view.clickable:
c = curr_view.clickable[-1]
print('Clicking {} {}, ({},{})'.format(c.getUniqueId(), c.getClass(),
c.getX(), c.getY()))
c.touch()
prev_clicked = c.getUniqueId()
del curr_view.clickable[-1]
else:
print 'Removing ' + curr_view.get_name() + ' from still_exploring.'
still_exploring.pop(curr_view.get_name(), 0)
print 'Clicking back button'
perform_press_back(device)
prev_view = curr_view
prev_clicked = BACK_BUTTON
# Make sure we have changed views.
vc_dump = perform_vc_dump(vc)
num_dumps = 0
while not vc_dump and num_dumps < MAX_DUMPS:
perform_press_back(device)
vc_dump = perform_vc_dump(vc)
num_dumps += 1
if num_dumps == MAX_DUMPS:
return
activity = obtain_activity_name(package_name, device)
if activity is EXITED_APP:
activity = return_to_app_activity(package_name, device)
if activity is EXITED_APP:
print 'Clicking back took us out of the app'
return
curr_view = obtain_curr_view(activity, package_name, vc_dump,
view_map, still_exploring, device)
if prev_view.is_duplicate_view(curr_view):
# We have nothing left to click, and the back button doesn't change
# views.
print 'Pressing back keeps at the current view'
return
else:
link_ui_views(prev_view, curr_view, 'back button', package_name)
else:
perform_press_back(device)
def crawl_package(vc, device, package_name=None):
"""Crawl entire package. Explore blindly, then return to unexplored views."""
set_device_dimens(vc, device)
# View map stores all Views that we have seen, while the still_exploring
# consists of only Views that have not been exhaustively explored yet (or
# found to be unreachable.)
view_map = {}
still_exploring = {}
if not package_name:
package_name = obtain_package_name(device)
# Store the root View
print 'Storing root'
vc_dump = perform_vc_dump(vc)
if not vc_dump:
return
activity = obtain_activity_name(package_name, device)
root_view = obtain_curr_view(activity, package_name, vc_dump, view_map,
still_exploring, device)
crawl_until_exit(vc, device, package_name, view_map, still_exploring,
root_view)
print 'Root is ' + root_view.get_name()
print 'We have seen ' + str(len(view_map)) + ' unique views.'
# Recrawl Views that aren't completely explored.
while still_exploring:
print 'We still have ' + str(len(still_exploring)) + ' views to explore.'
print 'Still need to explore: ' + str(still_exploring.keys())
v = still_exploring.values()[0]
print 'Now trying to explore '+ v.get_name()
path = find_path_from_root_to_view(v, view_map)
print 'Route from root to ' + v.get_name()
if path:
for p in path:
print p[0] + ' ' + p[1]
else:
print 'No path to ' + v.get_name()
# Restart the app with its initial screen.
subprocess.call([ADB_PATH, 'shell', 'am force-stop', package_name])
subprocess.call([ADB_PATH, 'shell', 'monkey', '-p', package_name, '-c',
'android.intent.category.LAUNCHER', '1'])
time.sleep(5)
reached_view = follow_path_to_view(path, v, package_name, device,
view_map, still_exploring, vc)
vc_dump = perform_vc_dump(vc)
activity = obtain_activity_name(package_name, device)
if activity == EXITED_APP:
break
curr_view = obtain_curr_view(activity, package_name, vc_dump, view_map,
still_exploring, device)
print 'Wanted ' + v.get_name() + ', at ' + curr_view.get_name()
if reached_view:
print 'Reached the view we were looking for.'
else:
print ('Did not reach intended view, removing ' + v.get_name() +
' from still_exploring.')
still_exploring.pop(v.get_name(), 0)
if curr_view.clickable:
# If we made it to our intended View, or at least a View with
# unexplored components, start crawling again.
print 'Crawling again'
crawl_until_exit(vc, device, package_name, view_map, still_exploring,
curr_view)
print ('Done with the crawl. Still ' + str(len(v.clickable)) +
' components to click for this View.')
print 'No more views to crawl'