blob: 3b3312d08edbaa53cb8428220e2e93db4a3c0c53 [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 config import Config
from layout import Layout
# https://material.google.com/layout/structure.html#structure-system-bars
NAVBAR_DP_HEIGHT = 48
MAX_X = 0
MAX_Y = 0
STATUS_BAR_HEIGHT = 0
# Visibility
VISIBLE = 0x0
INVISIBLE = 0x4
GONE = 0x8
ADB_PATH = obtainAdbPath()
SERIAL_NO = ''
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
# To prevent getting stuck in apps with a large number of UIs or dynamic content
# that can change the view hierarchy each time it's loaded, we limit the number
# of crawls to perform and max number of layouts to store per app.
MAX_CRAWLS = 20
MAX_LAYOUTS = 40
# We use this to prevent loops that can occur when back button behavior creates
# a cycle.
MAX_CONSEC_BACK_PRESSES = 10
MAX_FB_AUTH_TAPS = 5
MAX_FB_BUG_RESETS = 5
NEGATIVE_WORDS = ['no', 'cancel', 'back', 'neg' 'deny', 'prev', 'exit',
'delete', 'end', 'remove', 'clear', 'reset', 'undo']
# It's ok if the registered words also include the name of a social network
# (i.e. "Sign in with Google") and sends that view to the end of the list
# because the program will still go through the list of all clickable views
# looking for the social network logins.
REGISTERED_WORDS = ['sign in', 'log', 'already', 'member']
END_WORDS = NEGATIVE_WORDS + REGISTERED_WORDS
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_X, MAX_Y, STATUS_BAR_HEIGHT
try:
# Returns a string similar to "Physical size: 1440x2560"
size = device.shell('wm size')
# Returns a string similar to "Physical density: 560"
density = int(device.shell('wm density').split(' ')[-1])
# We do not want the crawler to click on the navigation bar because it can
# hit the back button or minimize the app.
# From https://developer.android.com/guide/practices/screens_support.html
# The conversion of dp units to screen pixels is simple:
# px = dp * (dpi / 160)
navbar_height = NAVBAR_DP_HEIGHT * density / 160
except IOError:
print '*** Socket timeout! Cannot get nav bar height.'
navbar_height = 0
MAX_X = int(extract_between(size, ': ', 'x'))
MAX_Y = int(extract_between(size, 'x', '\r')) - navbar_height
vc_dump = perform_vc_dump(vc)
if vc_dump:
STATUS_BAR_HEIGHT = (
vc_dump[0].getY() - int(vc_dump[0]['layout:getLocationOnScreen_y()']))
else:
# Keep status at default 0 height.
print 'Cannot get status bar height.'
def is_in_bounds(x, y):
return x >= 0 and x <= MAX_X and y >= STATUS_BAR_HEIGHT and y <= MAX_Y
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 touch(device, view):
"""Touches the corner of a view."""
# We don't use the AndroidViewClient view.touch() method because it touches
# the center of the view, which may be offscreen (or push a button on the nav
# bar).
try:
(x, y) = view.getXY()
device.touch(x, y)
print('Clicked {} {}, ({},{})'.format(view.getUniqueId(),
view.getClass(), view.getX(),
view.getY()))
except UnicodeEncodeError:
print '***Unicode coordinates'
except TypeError:
print '***String coordinates'
def use_keyboard(prev_clicked, config_data, device, vc):
"""Type text when the keyboard is visible."""
print 'Prev clicked: ' + prev_clicked
view = vc.findViewById(prev_clicked)
if not view:
# Sometimes when we get to a new Layout, an EditText is already selected.
# This means that prev_clicked will refer to a view from a previous Layout.
# The currently selected view will be clicked again during our crawl, so do
# not enter text now.
# TODO(afergan): This is just to check which view is selected, but since the
# dump takes time, remove this later.
vc_dump = perform_vc_dump(vc)
for v in vc_dump:
if v.isFocused():
print 'Focused: ' + v.getUniqueId()
break
perform_press_back(device)
return
# TODO(afergan): The dump does not include information about hints for the
# TextView, which can be very useful in the absence of a descriptive view id.
# See if there is a way to access this, or add this to our custom build of
# AOSP.
# https://developer.android.com/reference/android/widget/TextView.html#attr_android:hint)
# If there is already text in the field, do not add additional text.
if view and view.getText():
print 'This text field is already populated.'
perform_press_back(device)
return
# Check if the id contains any of the words in the [basic_info] section of the
# config. If any of these fields have been removed, do not type anything.
basic_info = config_data.get('basic_info')
if 'name' in prev_clicked:
if any(x in prev_clicked for x in['last', 'sur']):
print 'Typing last name ' + basic_info.get('last_name', '')
device.type(basic_info.get('last_name', ''))
elif any(x in prev_clicked for x in['user']):
print 'Typing username ' + basic_info.get('username', '')
else:
print 'Typing first name ' + basic_info.get('first_name', '')
device.type(basic_info.get('first_name', ''))
elif any(x in prev_clicked for x in['email', 'mail', 'address']):
print 'Typing email address ' + basic_info.get('email', '')
device.type(basic_info.get('email', ''))
elif any(x in prev_clicked for x in['password', 'pw']):
print 'Typing password ' + basic_info.get('password', '')
device.type(basic_info.get('password', ''))
elif any(x in prev_clicked for x in['zip']):
print 'Typing zip code ' + basic_info.get('zipcode', '')
device.type(basic_info.get('zipcode', ''))
elif any(x in prev_clicked for x in['phone']):
print 'Typing phone number ' + basic_info.get('phone_num', '')
device.type(basic_info.get('phone_num', ''))
else:
# If the user has added additional fields in the config, check for those.
for c in config_data.get('extra'):
if any(x in prev_clicked for x in c):
device.type(config_data.get('extra').get(c, ''))
break
else:
print 'Typing default text ' + basic_info.get('default', '')
device.type(basic_info.get('default', ''))
# TODO(afergan): The enter key can sometimes advance us to the next field or
# Layout, but we would have to track that here. For now, just minimize the
# keyboard and let the crawler advance us.
perform_press_back(device)
return
def fb_login(package_name, device, curr_layout, click, vc):
"""Log into Facebook by automating the authentication flow."""
# Get the full name of the current activity.
focus_str = device.shell("dumpsys window windows | grep -E 'mCurrentFocus'")
app_activity = extract_between(focus_str, ' ', '}', -1)
print 'App activity: ' + app_activity
print 'Trying to log into Facebook.'
# Sometimes touch() doesn't work
curr_layout.clickable.remove(click)
device.shell('input tap ' + str(click.getX()) +
' ' + str(click.getY()))
# Make sure the new screen is loaded by waiting for the dump.
perform_vc_dump(vc)
activity_str = obtain_focused_activity(device, vc)
print activity_str
# For a weird bug where the Facebook app sometimes repeatedly
# flashes a splashscreen and does not advance to the login.
f = 0
while activity_str == 'com.facebook.katana.app.FacebookSplashScreenActivity':
# We were not able to get past the bug.
if f >= MAX_FB_BUG_RESETS:
print 'Could not get past Facebook bug.'
return False
print 'Facebook bug! ' + str(f)
# Clear, stop, and relaunch Facebook.
device.shell('adb shell pm clear com.facebook.katana')
device.shell('am force-stop com.facebook.katana')
device.shell('monkey -p com.facebook.katana -c '
'android.intent.category.LAUNCHER 1')
time.sleep(2)
# Relaunch the app with the previous activity.
out = device.shell('su 0 am start -n ' + app_activity)
if any(x in out for x in['Error', 'Warning']):
# TODO(afergan): Relaunch the app and follow the shortest path to here.
return False
time.sleep(5)
device.shell('input tap ' + str(click.getX()) + ' ' + str(click.getY()))
time.sleep(5)
activity_str = obtain_focused_activity(device, vc)
print activity_str
f += 1
activity_str = obtain_focused_activity(device, vc)
if activity_str == 'com.facebook.katana.ProxyAuthDialog':
print 'Logging in'
# Because the Facebook authorization dialog is primarily a
# WebView, we must click on x, y coordinates of the Continue
# button instead of looking at the hierarchy.
device.shell('input tap ' + str(int(.5 * MAX_X)) + ' ' +
str(int(.82 * MAX_Y)))
perform_vc_dump(vc)
activity_str = obtain_focus_and_allow_permissions(device, vc)
# Authorize app to post to Facebook (or any other action).
num_taps = 0
while 'ProxyAuthDialog' in activity_str and num_taps < MAX_FB_AUTH_TAPS:
print 'Facebook authorization #' + str(num_taps)
device.shell('input tap ' + str(int(.90 * MAX_X)) + ' ' +
str(int(.95 * MAX_Y)))
num_taps += 1
time.sleep(3)
activity_str = obtain_focus_and_allow_permissions(device, vc)
return True
else:
print 'Could not log into Facebook.'
print activity_str + ' ' + str(obtain_frag_list(package_name, device))
return False
def google_login(device, curr_layout, click, vc):
"""Log into Google by automating the authentication flow."""
# TODO(afergan): Figure out if this fails or if the button doesn't lead to a
# login.
print 'Trying to log into Google.'
curr_layout.clickable.remove(click)
touch(device, click)
time.sleep(4)
# Make sure the new screen is loaded by waiting for the dump.
vc_dump = perform_vc_dump(vc)
if not vc_dump:
return False
# Some apps want to access contacts to get user information.
activity_str = obtain_focus_and_allow_permissions(device, vc)
print activity_str
if 'com.google.android.gms' not in activity_str:
return False
print 'Logging into G+'
# Some apps ask to pick the Google user before logging in.
if 'AccountChipAccountPickerActivity' in activity_str:
print 'Selecting user.'
v = vc.findViewById('id/account_profile_picture')
if v:
touch(device, v)
print 'Selected user.'
time.sleep(4)
perform_vc_dump(vc)
activity_str = obtain_focus_and_allow_permissions(device, vc)
print activity_str
if 'GrantCredentialsWithAclActivity' in activity_str:
print 'Granting credentials.'
perform_vc_dump(vc)
v = vc.findViewById('id/accept_button')
if v:
print 'Granting'
touch(device, v)
time.sleep(4)
return True
def return_to_app_activity(package_name, device, vc):
"""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, vc)
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_focused_activity(device, vc):
"""Returns the activity ."""
# 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 backslash and the closing bracket.
activity_str = obtain_focus_and_allow_permissions(device, vc)
if not activity_str:
return ''
return extract_between(activity_str, '/', '}', -1)
def obtain_focus_and_allow_permissions(device, vc):
"""Accepts any permission prompts and returns the current focus."""
activity_str = device.shell("dumpsys window windows "
"| grep -E 'mCurrentFocus'")
# If the app is prompting for permissions, automatically accept them.
while 'com.android.packageinstaller' in activity_str:
print 'Allowing a permission.'
perform_vc_dump(vc)
touch(device, vc.findViewById('id/permission_allow_button'))
time.sleep(2)
activity_str = device.shell("dumpsys window windows "
"| grep -E 'mCurrentFocus'")
# Keycodes are from
# https://developer.android.com/reference/android/view/KeyEvent.html
# If a physical device is at the lockscreen, unlock it.
if 'StatusBar' in activity_str:
# If the screen is off, turn it on.
if (device.shell("dumpsys power | grep 'Display Power: state=' | grep -oE "
"'(ON|OFF)'") == 'OFF'):
device.press('KEYCODE_POWER')
# Unlock device.
device.press('KEYCODE_MENU')
activity_str = device.shell("dumpsys window windows "
"| grep -E 'mCurrentFocus'")
return activity_str
def obtain_activity_name(package_name, device, vc):
"""Gets the current running activity of the package."""
activity_str = obtain_focus_and_allow_permissions(device, vc)
# 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)
print 'Not in package. Current activity string is ' + activity_str
return EXITED_APP
def obtain_frag_list(package_name, device):
"""Gets the list of fragments in the current layout."""
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)
# For irregular or app-generated fragment names with spaces and IDs,
# terminate the name at the first space.
for i in range(0, len(frag_list)):
if ' ' in frag_list[i]:
frag_list[i] = frag_list[i].split()[0]
return frag_list
return []
def obtain_package_name(device, vc):
"""Gets the package name of the current focused window."""
activity_str = obtain_focus_and_allow_permissions(device, vc)
# 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_layout(stored_layout, package_name, device, vc):
"""Check if the current Layout name matches a stored Layout."""
print str(obtain_frag_list(package_name, device))
print ('Curr activity / frag list: ' +
obtain_activity_name(package_name, device, vc) + ' ' +
str(obtain_frag_list(package_name, device)))
print ('Stored activity + frag list: ' + stored_layout.activity + ' ' +
str(stored_layout.frag_list))
return (obtain_activity_name(package_name, device, vc) ==
stored_layout.activity and Counter(obtain_frag_list(package_name,
device)) ==
Counter(stored_layout.frag_list))
def save_layout_data(package_name, device, 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')
layout_info = {}
layout_info['hierarchy'] = {}
layout_info['fragmentList'] = frag_list
for view 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(view.__dict__)
del dict_copy['device']
if dict_copy['parent']:
dict_copy['parent'] = dict_copy['parent'].getUniqueId()
dict_copy['children'] = []
for child in view.__dict__['children']:
dict_copy['children'].append(child.getUniqueId())
layout_info['hierarchy'][view.getUniqueId()] = dict_copy
with open(dump_file, 'w') as out_file:
try:
json.dump(layout_info, out_file, indent=2)
except TypeError:
print 'Non-JSON serializable object in Layout.'
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.
device.shell('screencap /sdcard/' + screen_name)
subprocess.call([ADB_PATH, '-s', SERIAL_NO, 'pull', '/sdcard/' + screen_name,
screen_path])
device.shell('rm /sdcard/' + screen_name)
# Returns the filename & num so that the screenshot can be accessed
# programatically.
return screen_path, file_num
def save_ui_flow_relationships(layout_to_save, package_name):
"""Dumps to file the click dictionary and preceding Layouts."""
directory = (os.path.dirname(os.path.abspath(__file__)) + '/data/' +
package_name)
click_file = os.path.join(directory, layout_to_save.get_name() +
'-clicks.json')
click_info = {}
click_info['click_dict'] = layout_to_save.click_dict
click_info['preceding'] = layout_to_save.preceding
click_info['depth'] = layout_to_save.depth
with open(click_file, 'w') as out_file:
json.dump(click_info, out_file, indent=2)
def find_layout_in_map(activity, frag_list, vc_dump, layout_map):
"""Finds the current Layout in the layout array (empty if new Layout)."""
# TODO(afergan): Consider creating another map indexed by the values compared
# in is_duplicate so that this comparison is O(1).
for val in layout_map.values():
if val.is_duplicate(activity, frag_list, vc_dump):
return val
return None
def create_layout(package_name, device, vc_dump, activity, frag_list):
"""Stores the current layout in the Layout data structure."""
screenshot, num = save_layout_data(package_name, device, activity, frag_list,
vc_dump)
# If we think the first element in the view hierarchy is a back button, move
# it to the end of the list so that we click on it last.
if 'back' in vc_dump[0].getUniqueId().lower():
vc_dump.append(vc_dump.pop())
l = Layout(activity, frag_list, vc_dump, screenshot, num)
for view in l.hierarchy:
try:
if (view.isClickable() and view.getVisibility() == VISIBLE and
is_in_bounds(view.getX(), view.getY()) and view.getWidth() > 0 and
view.getHeight() > 0):
if view.getText():
print (view.getId() + ' ' + view.getClass() + ' ' +
str(view.getXY()) + ' ' + view.getText() +
'-- will be clicked')
else:
print (view.getId() + ' ' + view.getClass() + ' ' +
str(view.getXY()) + '-- will be clicked')
l.clickable.append(view)
except AttributeError:
print 'Could not get view attributes.'
# For views that cancel or bring us back, click on them last. However, do not
# hold this against views with the unique id id/no_id/##.
for clickview in l.clickable:
clickstr = ''
if 'no_id' in clickview.getUniqueId().lower():
clickstr = ''
else:
clickstr = clickview.getUniqueId().lower()
if clickview.getText():
print 'Text: ' + clickview.getText()
clickstr += ' ' + clickview.getText().lower()
if any(x in clickstr for x in END_WORDS):
print 'Going to the end of the list b/c of text or ID: ' + clickstr
l.clickable.remove(clickview)
l.clickable.append(clickview)
return l
def link_ui_layouts(prev_layout, curr_layout, prev_clicked, package_name):
"""Stores the relationship between prev_layout and curr_layout."""
# We store in the Layout information that the last layout links to the current
# layout, and that the current layout can be reached from the last layout. We
# use the id of the last clicked element as the dictionary key so that we know
# which element leads from layout to layout.
if prev_clicked:
print 'Previous clicked: ' + prev_clicked
prev_layout.click_dict[prev_clicked] = curr_layout.get_name()
prev_name = prev_layout.get_name()
if prev_name not in curr_layout.preceding:
curr_layout.preceding.append(prev_name)
else:
print 'Lost track of last clicked!'
print 'Prev layout: ' + prev_layout.get_name()
print 'Curr layout: ' + curr_layout.get_name()
if curr_layout.depth == -1 or curr_layout.depth > prev_layout.depth + 1:
curr_layout.depth = prev_layout.depth + 1
# TODO(afergan): Remove this later. For debugging, we print the clicks after
# each click to a new layout 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_layout, package_name)
save_ui_flow_relationships(curr_layout, package_name)
def obtain_curr_layout(activity, package_name, vc_dump, layout_map,
still_exploring, device):
"""Extracts UI info and return the current Layout."""
# Gets the current UI info. If we have seen this UI before, return the
# existing Layout. If not, create a new Layout and save it to the layout
# array.
frag_list = obtain_frag_list(package_name, device)
layout = find_layout_in_map(activity, frag_list, vc_dump, layout_map)
if layout:
print 'Found duplicate'
return layout
else:
print 'New layout'
new_layout = create_layout(package_name, device, vc_dump, activity,
frag_list)
# Make sure we have a valid Layout. This will be false if we get a socket
# timeout.
if new_layout.get_name():
layout_map[new_layout.get_name()] = new_layout
# If there are clickable views, explore this new Layout.
if new_layout.clickable:
still_exploring[new_layout.get_name()] = new_layout
print ('Added ' + new_layout.get_name() + ' to still_exploring. Length '
'is now ' + str(len(still_exploring)))
return new_layout
print 'Could not obtain current layout.'
return None
def find_view_to_lead_to_layout(layout1, layout2):
"""Given 2 Layouts, return the view of layout 1 that leads to layout 2."""
try:
return layout1.click_dict.keys()[layout1.click_dict.values().index(
layout2.get_name())]
except ValueError:
print '*** Could not find a view to link to the succeeding Layout!'
print (str(layout1.click_dict) + ' does not have a path to ' +
layout2.get_name())
return FAILED_FINDING_NAME
def find_shortest_path(graph, start, end, prevpath=()):
"""Use BFS to find the shortest path from the start to end node."""
# Modified from http://stackoverflow.com/a/8922151/1076508 to prevent cycles
# and account for already visited nodes.
queue = [[start]]
visited = set(prevpath)
while queue:
path = queue.pop(0)
node = path[-1]
# Path found.
if node == end:
return path
# Enumerate all adjacent nodes, construct a new path and push it into the
# queue.
for adjacent in graph.get(node, []):
if adjacent not in visited:
new_path = list(path)
new_path.append(adjacent)
queue.append(new_path)
visited.add(adjacent)
def follow_path_to_layout(path, goal, package_name, device, layout_map,
layout_graph, still_exploring, vc):
"""Attempt to follow path all the way to the desired layout."""
# We need to look at the length of path for each iteration since the path can
# change when we get off course.
i = 0
while i < len(path) - 1:
# We can be lenient here and only evaluate if the activity and fragments are
# the same (and allow the layout hierarchy to have changed a little bit),
# since we then evaluate if the clickable view we want is in the Layout.
p = path[i]
p_layout = layout_map.get(p)
if is_active_layout(p_layout, package_name, device, vc):
print 'Got to ' + p
click_id = find_view_to_lead_to_layout(p_layout,
layout_map.get(path[i+1]))
if i > 0 and (layout_map.get(path[i-1]).depth + 1 <
layout_map.get(path[i]).depth):
layout_map.get(path[i]).depth = layout_map.get(path[i-1]).depth + 1
if click_id == FAILED_FINDING_NAME:
print ('Could not find the right view to click on, was looking for ' +
click_id)
return False
if click_id == BACK_BUTTON:
perform_press_back(device)
else:
vc_dump = perform_vc_dump(vc)
if vc_dump:
click_target = next((view for view in vc_dump
if view.getUniqueId() == click_id), None)
if click_target:
prev_clicked = click_target.getUniqueId()
touch(device, click_target)
else:
return False
else:
print 'Toto, I\'ve a feeling we\'re not on the right path anymore.'
# Remove the edge from the graph so that we don't follow it again (but
# don't remove it from our data collection.
try:
layout_graph[p].remove(path[i+1])
print 'Removed edge from ' + p + ' to ' + path[i+1]
except KeyError:
print ('??? Could not find edge from ' + p + ' to ' + path[i+1] +
' to remove from graph.')
# Figure out where we are & link it to the previous layout, but then try
# to still get to the intended Layout.
activity = obtain_activity_name(package_name, device, vc)
if activity is EXITED_APP:
activity = return_to_app_activity(package_name, device, vc)
vc_dump = perform_vc_dump(vc)
curr_layout = obtain_curr_layout(activity, package_name, vc_dump,
layout_map, still_exploring, device)
prev_layout = layout_map.get(p)
path_to_curr = path[:i]
if not prev_layout.is_duplicate_layout(curr_layout):
link_ui_layouts(prev_layout, curr_layout, prev_clicked, package_name)
path_to_curr.append(curr_layout.get_name())
new_path = find_shortest_path(layout_graph, curr_layout.get_name(),
goal.get_name(), path_to_curr)
if new_path:
print 'Back on track -- found new route to ' + goal.get_name()
path += new_path
else:
print 'Stopping here. Could not find a way to ' + goal.get_name()
return
i += 1
# We made it all the way through!
if i == len(path) - 1:
print 'Got to end of path.'
if layout_map.get(path[i-1]).depth + 1 < layout_map.get(path[i]).depth:
layout_map.get(path[i]).depth = layout_map.get(path[i-1]).depth + 1
# Make sure that we end up at the Layout that we want.
return is_active_layout(goal, package_name, device, vc)
def crawl_until_exit(vc, device, package_name, layout_map, layout_graph,
still_exploring, start_layout, logged_in, config_data):
"""Main crawler loop. Evaluates layouts, stores new data, and clicks views."""
print 'Logged in: ' + str(logged_in)
curr_layout = start_layout
prev_clicked = ''
consec_back_presses = 0
while (len(layout_map) < MAX_LAYOUTS and
consec_back_presses < MAX_CONSEC_BACK_PRESSES):
if device.isKeyboardShown():
perform_press_back(device)
activity = obtain_activity_name(package_name, device, vc)
if activity is EXITED_APP:
activity = return_to_app_activity(package_name, device, vc)
if activity is EXITED_APP:
print 'Current layout is not app and we cannot return'
break
else:
prev_clicked = BACK_BUTTON
prev_layout = curr_layout
vc_dump = perform_vc_dump(vc)
if vc_dump:
curr_layout = obtain_curr_layout(activity, package_name, vc_dump,
layout_map, still_exploring, device)
print 'Curr layout: ' + curr_layout.get_name()
if not prev_layout.is_duplicate_layout(curr_layout):
print 'At a diff layout!'
link_ui_layouts(prev_layout, curr_layout, prev_clicked, package_name)
prev_name = prev_layout.get_name()
if prev_name in layout_graph:
print 'New set: ' + prev_name + ' ' + curr_layout.get_name()
layout_graph.get(prev_name).add(curr_layout.get_name())
else:
layout_graph[prev_name] = {curr_layout.get_name()}
print 'Adding to set: ' + prev_name + ' ' + curr_layout.get_name()
print 'Num of nodes in layout graph: ' + str(len(layout_graph))
print 'Layout depth: ' + str(curr_layout.depth)
print 'Num clickable: ' + str(len(curr_layout.clickable))
if curr_layout.clickable:
found_login = False
if not logged_in:
for click in curr_layout.clickable:
clickid = click.getUniqueId().lower()
if click.getText():
clicktext = click.getText().lower()
else:
clicktext = ''
if (click.getClass() == 'com.facebook.widget.LoginButton'
or any('facebook' in x for x in [clickid, clicktext])
or ('fb' in clickid and any(s in clickid for s in
['login', 'log_in', 'signin',
'sign_in']))):
found_login = True
consec_back_presses = 0
prev_clicked = click.getUniqueId()
logged_in = fb_login(package_name, device, curr_layout, click, vc)
elif (click.getClass ==
'com.google.android.gms.common.SignInButton' or
any('google' in x for x in [clickid, clicktext]) or
any('gplus' in x for x in [clickid, clicktext]) or
clickid == 'sign_in_button'):
found_login = True
consec_back_presses = 0
prev_clicked = click.getUniqueId()
logged_in = google_login(device, curr_layout, click, vc)
if not found_login:
c = curr_layout.clickable[0]
touch(device, c)
consec_back_presses = 0
prev_clicked = c.getUniqueId()
curr_layout.clickable.remove(c)
if device.isKeyboardShown():
use_keyboard(prev_clicked, config_data, device, vc)
else:
print 'Removing ' + curr_layout.get_name() + ' from still_exploring.'
still_exploring.pop(curr_layout.get_name(), 0)
consec_back_presses += 1
print ('Clicking back button, consec_back_presses is ' +
str(consec_back_presses))
perform_press_back(device)
prev_layout = curr_layout
prev_clicked = BACK_BUTTON
# Make sure we have changed layouts.
vc_dump = perform_vc_dump(vc)
num_dumps = 0
while not vc_dump and num_dumps < MAX_DUMPS:
perform_press_back(device)
consec_back_presses += 1
vc_dump = perform_vc_dump(vc)
num_dumps += 1
if num_dumps == MAX_DUMPS:
print 'Could not get a ViewClient dump.'
break
activity = obtain_activity_name(package_name, device, vc)
if activity is EXITED_APP:
activity = return_to_app_activity(package_name, device, vc)
if activity is EXITED_APP:
print 'Clicking back took us out of the app.'
break
if vc_dump:
curr_layout = obtain_curr_layout(activity, package_name, vc_dump,
layout_map, still_exploring, device)
if prev_layout.is_duplicate_layout(curr_layout):
# We have nothing left to click, and the back button doesn't change
# layouts.
print 'Pressing back keeps at the current layout.'
break
else:
link_ui_layouts(prev_layout, curr_layout, 'back button',
package_name)
else:
perform_press_back(device)
consec_back_presses += 1
return logged_in
def crawl_package(vc, device, serialno, package_name=None):
"""Crawl package. Explore blindly, then return to unexplored layouts."""
global SERIAL_NO
SERIAL_NO = serialno
set_device_dimens(vc, device)
# Layout map stores all Layouts that we have seen, while the still_exploring
# consists of only Layouts that have not been exhaustively explored yet (or
# found to be unreachable.) The Layout graph stores all of the connections
# between different screens.
layout_map = {}
still_exploring = {}
layout_graph = {}
config_data = Config().data
settings = config_data.get('settings')
if settings:
if settings.get('lock_portrait_mode'):
# TODO(afergan): Is this best to do after each app (in case an app changes
# the phone's settings) or just run when we start the program?
# Lock phone orientation to portrait.
# Turn off automatic rotation.
device.shell('content insert --uri content://settings/system --bind '
'name:s:accelerometer_rotation --bind value:i:0')
# Rotate to portrait mode.
device.shell('content insert --uri content://settings/system --bind '
'name:s:user_rotation --bind value:i:0')
if settings.get('clear_notifications'):
# Clear all notifications.
device.shell('su 0 service call notification 1')
# Stores if we have logged in during this crawl/session. If the app has
# previously logged into an app or service (and can skip the authorization
# process), we will be unable to detect that.
# TODO(afergan): Is there a way to determine if we've already authorized a
# media service? Clicking on Facebook once we've already authorized it just
# pops up a momentary dialog then goes to the next screen, so it would be
# difficult to differentiate an authorized login from a normal button that
# happened to be named "facebook_login" or a failed login.
logged_in = False
if not package_name:
package_name = obtain_package_name(device, vc)
activity = obtain_activity_name(package_name, device, vc)
if activity == EXITED_APP:
return
vc_dump = perform_vc_dump(vc)
if not vc_dump:
return
first_layout = obtain_curr_layout(activity, package_name, vc_dump, layout_map,
still_exploring, device)
first_layout.depth = 0
print 'Root is ' + first_layout.get_name()
num_crawls = 0
logged_in = crawl_until_exit(vc, device, package_name, layout_map,
layout_graph, still_exploring, first_layout,
logged_in, config_data)
# Recrawl Layouts that aren't completely explored.
while (still_exploring and num_crawls < MAX_CRAWLS and
len(layout_map) < MAX_LAYOUTS):
print 'Crawl #' + str(num_crawls)
num_crawls += 1
print 'We have seen ' + str(len(layout_map)) + ' unique layouts.'
print 'We still have ' + str(len(still_exploring)) + ' layouts to explore.'
print 'Still need to explore: ' + str(still_exploring.keys())
l = still_exploring.values()[0]
print 'Now trying to explore '+ l.get_name()
# Restart the app with its initial screen.
device.shell('am force-stop ' + package_name)
device.shell('monkey -p ' + package_name +
' -c android.intent.category.LAUNCHER 1')
time.sleep(5)
activity = obtain_activity_name(package_name, device, vc)
if activity == EXITED_APP:
print 'Could not launch app.'
return
starting_layout = obtain_curr_layout(activity, package_name, vc_dump,
layout_map, still_exploring, device)
starting_layout.depth = 0
print 'Starting layout: ' + starting_layout.get_name()
path = find_shortest_path(layout_graph, starting_layout.get_name(),
l.get_name())
if path:
print ('Shortest path from ' + starting_layout.get_name() + ' to ' +
l.get_name() + ': ' + str(path))
reached_layout = follow_path_to_layout(path, l, package_name, device,
layout_map, layout_graph,
still_exploring, vc)
if reached_layout:
print 'Reached the layout we were looking for.'
else:
print ('Did not reach intended layout, removing ' + l.get_name() +
' from still_exploring.')
still_exploring.pop(l.get_name(), 0)
activity = obtain_activity_name(package_name, device, vc)
else:
print 'No path to ' + l.get_name() + '. Removing from still_exploring.'
still_exploring.pop(l.get_name(), 0)
if activity != EXITED_APP:
vc_dump = perform_vc_dump(vc)
if vc_dump:
curr_layout = obtain_curr_layout(activity, package_name, vc_dump,
layout_map, still_exploring, device)
print 'Wanted ' + l.get_name() + ', at ' + curr_layout.get_name()
if curr_layout.clickable:
# If we made it to our intended Layout, or at least a Layout with
# unexplored views, start crawling again.
print 'Crawling again'
logged_in = crawl_until_exit(vc, device, package_name, layout_map,
layout_graph, still_exploring,
curr_layout, logged_in, config_data)
print ('Done with the crawl. Still ' + str(len(l.clickable)) +
' views to click for this Layout.')
else:
print 'Nothing left to click for ' + l.get_name()
still_exploring.pop(l.get_name(), 0)
print 'No more layouts to crawl.'