| # 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.' |