[WifiPasspointTest] UI for boingo passpoint

CHERRY PICKED CHANGES FROM git_master and aosp_master

Bug: 148611705
Test: Verified changes on all devices
Merged-In: Ieb6b7df5598e3242a4e65c50e8e2eb410918fbc8
Change-Id: I7e1115031520b5dff3c577fd245847560482b610
diff --git a/acts/framework/acts/test_utils/net/ui_utils.py b/acts/framework/acts/test_utils/net/ui_utils.py
new file mode 100644
index 0000000..4dadda1
--- /dev/null
+++ b/acts/framework/acts/test_utils/net/ui_utils.py
@@ -0,0 +1,270 @@
+"""Utils for adb-based UI operations."""
+
+import collections
+import logging
+import os
+import re
+import time
+
+from xml.dom import minidom
+from acts.controllers.android_lib.errors import AndroidDeviceError
+
+
+class Point(collections.namedtuple('Point', ['x', 'y'])):
+
+  def __repr__(self):
+    return '{x},{y}'.format(x=self.x, y=self.y)
+
+
+class Bounds(collections.namedtuple('Bounds', ['start', 'end'])):
+
+  def __repr__(self):
+    return '[{start}][{end}]'.format(start=str(self.start), end=str(self.end))
+
+  def calculate_middle_point(self):
+    return Point((self.start.x + self.end.x) // 2,
+                 (self.start.y + self.end.y) // 2)
+
+
+def get_key_value_pair_strings(kv_pairs):
+  return ' '.join(['%s="%s"' % (k, v) for k, v in kv_pairs.items()])
+
+
+def parse_bound(bounds_string):
+  """Parse UI bound string.
+
+  Args:
+    bounds_string: string, In the format of the UI element bound.
+                   e.g '[0,0][1080,2160]'
+
+  Returns:
+    Bounds, The bound of UI element.
+  """
+  bounds_pattern = re.compile(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]')
+  points = bounds_pattern.match(bounds_string).groups()
+  points = list(map(int, points))
+  return Bounds(Point(*points[:2]), Point(*points[-2:]))
+
+
+def _find_point_in_bounds(bounds_string):
+  """Finds a point that resides within the given bounds.
+
+  Args:
+    bounds_string: string, In the format of the UI element bound.
+
+  Returns:
+    A tuple of integers, representing X and Y coordinates of a point within
+    the given boundary.
+  """
+  return parse_bound(bounds_string).calculate_middle_point()
+
+
+def get_screen_dump_xml(device):
+  """Gets an XML dump of the current device screen.
+
+  This only works when there is no instrumentation process running. A running
+  instrumentation process will disrupt calls for `adb shell uiautomator dump`.
+
+  Args:
+    device: AndroidDevice object.
+
+  Returns:
+    XML Document of the screen dump.
+  """
+  os.makedirs(device.log_path, exist_ok=True)
+  device.adb.shell('uiautomator dump')
+  device.adb.pull('/sdcard/window_dump.xml %s' % device.log_path)
+  return minidom.parse('%s/window_dump.xml' % device.log_path)
+
+
+def match_node(node, **matcher):
+  """Determine if a mode matches with the given matcher.
+
+  Args:
+    node: Is a XML node to be checked against matcher.
+    **matcher: Is a dict representing mobly AdbUiDevice matchers.
+
+  Returns:
+    True if all matchers match the given node.
+  """
+  match_list = []
+  for k, v in matcher.items():
+    if k == 'class_name':
+      key = k.replace('class_name', 'class')
+    elif k == 'text_contains':
+      key = k.replace('text_contains', 'text')
+    else:
+      key = k.replace('_', '-')
+    try:
+      if k == 'text_contains':
+        match_list.append(v in node.attributes[key].value)
+      else:
+        match_list.append(node.attributes[key].value == v)
+    except KeyError:
+      match_list.append(False)
+  return all(match_list)
+
+
+def _find_node(screen_dump_xml, **kwargs):
+  """Finds an XML node from an XML DOM.
+
+  Args:
+    screen_dump_xml: XML doc, parsed from adb ui automator dump.
+    **kwargs: key/value pairs to match in an XML node's attributes. Value of
+      each key has to be string type. Below lists keys which can be used:
+        index
+        text
+        text_contains (matching a part of text attribute)
+        resource_id
+        class_name (representing "class" attribute)
+        package
+        content_desc
+        checkable
+        checked
+        clickable
+        enabled
+        focusable
+        focused
+        scrollable
+        long_clickable
+        password
+        selected
+
+  Returns:
+    XML node of the UI element or None if not found.
+  """
+  nodes = screen_dump_xml.getElementsByTagName('node')
+  for node in nodes:
+    if match_node(node, **kwargs):
+      logging.debug('Found a node matching conditions: %s',
+                    get_key_value_pair_strings(kwargs))
+      return node
+
+
+def wait_and_get_xml_node(device, timeout, child=None, sibling=None, **kwargs):
+  """Waits for a node to appear and return it.
+
+  Args:
+    device: AndroidDevice object.
+    timeout: float, The number of seconds to wait for before giving up.
+    child: dict, a dict contains child XML node's attributes. It is extra set of
+      conditions to match an XML node that is under the XML node which is found
+      by **kwargs.
+    sibling: dict, a dict contains sibling XML node's attributes. It is extra
+      set of conditions to match an XML node that is under parent of the XML
+      node which is found by **kwargs.
+    **kwargs: Key/value pairs to match in an XML node's attributes.
+
+  Returns:
+    The XML node of the UI element.
+
+  Raises:
+    AndroidDeviceError: if the UI element does not appear on screen within
+    timeout or extra sets of conditions of child and sibling are used in a call.
+  """
+  if child and sibling:
+    raise AndroidDeviceError(
+        device, 'Only use one extra set of conditions: child or sibling.')
+  start_time = time.time()
+  threshold = start_time + timeout
+  while time.time() < threshold:
+    time.sleep(1)
+    screen_dump_xml = get_screen_dump_xml(device)
+    node = _find_node(screen_dump_xml, **kwargs)
+    if node and child:
+      node = _find_node(node, **child)
+    if node and sibling:
+      node = _find_node(node.parentNode, **sibling)
+    if node:
+      return node
+  msg = ('Timed out after %ds waiting for UI node matching conditions: %s.'
+         % (timeout, get_key_value_pair_strings(kwargs)))
+  if child:
+    msg = ('%s extra conditions: %s'
+           % (msg, get_key_value_pair_strings(child)))
+  if sibling:
+    msg = ('%s extra conditions: %s'
+           % (msg, get_key_value_pair_strings(sibling)))
+  raise AndroidDeviceError(device, msg)
+
+
+def has_element(device, **kwargs):
+  """Checks a UI element whether appears or not in the current screen.
+
+  Args:
+    device: AndroidDevice object.
+    **kwargs: Key/value pairs to match in an XML node's attributes.
+
+  Returns:
+    True if the UI element appears in the current screen else False.
+  """
+  timeout_sec = kwargs.pop('timeout', 30)
+  try:
+    wait_and_get_xml_node(device, timeout_sec, **kwargs)
+    return True
+  except AndroidDeviceError:
+    return False
+
+
+def get_element_attributes(device, **kwargs):
+  """Gets a UI element's all attributes.
+
+  Args:
+    device: AndroidDevice object.
+    **kwargs: Key/value pairs to match in an XML node's attributes.
+
+  Returns:
+    XML Node Attributes.
+  """
+  timeout_sec = kwargs.pop('timeout', 30)
+  node = wait_and_get_xml_node(device, timeout_sec, **kwargs)
+  return node.attributes
+
+
+def wait_and_click(device, duration_ms=None, **kwargs):
+  """Wait for a UI element to appear and click on it.
+
+  This function locates a UI element on the screen by matching attributes of
+  nodes in XML DOM, calculates a point's coordinates within the boundary of the
+  element, and clicks on the point marked by the coordinates.
+
+  Args:
+    device: AndroidDevice object.
+    duration_ms: int, The number of milliseconds to long-click.
+    **kwargs: A set of `key=value` parameters that identifies a UI element.
+  """
+  timeout_sec = kwargs.pop('timeout', 30)
+  button_node = wait_and_get_xml_node(device, timeout_sec, **kwargs)
+  x, y = _find_point_in_bounds(button_node.attributes['bounds'].value)
+  args = []
+  if duration_ms is None:
+    args = 'input tap %s %s' % (str(x), str(y))
+  else:
+    # Long click.
+    args = 'input swipe %s %s %s %s %s' % \
+        (str(x), str(y), str(x), str(y), str(duration_ms))
+  device.adb.shell(args)
+
+def wait_and_input_text(device, input_text, duration_ms=None, **kwargs):
+  """Wait for a UI element text field that can accept text entry.
+
+  This function located a UI element using wait_and_click. Once the element is
+  clicked, the text is input into the text field.
+
+  Args:
+    device: AndroidDevice, Mobly's Android controller object.
+    input_text: Text string to be entered in to the text field.
+    duration_ms: duration in milliseconds.
+    **kwargs: A set of `key=value` parameters that identifies a UI element.
+  """
+  wait_and_click(device, duration_ms, **kwargs)
+  # Replace special characters.
+  # The command "input text <string>" requires special treatment for
+  # characters ' ' and '&'.  They need to be escaped. for example:
+  #    "hello world!!&" needs to transform to "hello\ world!!\&"
+  special_chars = ' &'
+  for c in special_chars:
+    input_text = input_text.replace(c, '\\%s' % c)
+  input_text = "'" + input_text + "'"
+  args = 'input text %s' % input_text
+  device.adb.shell(args)
diff --git a/acts/tests/google/net/CaptivePortalTest.py b/acts/tests/google/net/CaptivePortalTest.py
index 0611640..7df4372 100644
--- a/acts/tests/google/net/CaptivePortalTest.py
+++ b/acts/tests/google/net/CaptivePortalTest.py
@@ -17,10 +17,10 @@
 
 from acts import asserts
 from acts import base_test
-from acts.libs.uicd.uicd_cli import UicdCli
 from acts.test_decorators import test_tracker_info
 from acts.test_utils.net import connectivity_const as cconst
 from acts.test_utils.net import connectivity_test_utils as cutils
+from acts.test_utils.net import ui_utils as uutils
 from acts.test_utils.wifi import wifi_test_utils as wutils
 from acts.test_utils.wifi.WifiBaseTest import WifiBaseTest
 
@@ -28,6 +28,7 @@
 IFACE = "InterfaceName"
 TIME_OUT = 20
 WLAN = "wlan0"
+ACCEPT_CONTINUE = "Accept and Continue"
 
 
 class CaptivePortalTest(base_test.BaseTestClass):
@@ -45,35 +46,37 @@
           4. uic_zip: Zip file location of UICD application
         """
         self.dut = self.android_devices[0]
-        wutils.wifi_test_device_init(self.dut)
-        wutils.wifi_toggle_state(self.dut, True)
-        req_params = ["rk_captive_portal",
-                      "gg_captive_portal",
-                      "uicd_workflows",
-                      "uicd_zip"]
+        req_params = ["rk_captive_portal", "gg_captive_portal"]
         self.unpack_userparams(req_param_names=req_params,)
-        self.ui = UicdCli(self.uicd_zip, self.uicd_workflows)
-        self.rk_workflow_config = "rk_captive_portal_%s" % self.dut.model
-        self.gg_workflow_config = "gg_captive_portal_%s" % self.dut.model
+        wutils.wifi_test_device_init(self.dut)
 
     def teardown_class(self):
         """Reset devices."""
         cutils.set_private_dns(self.dut, cconst.PRIVATE_DNS_MODE_OPPORTUNISTIC)
+        wutils.reset_wifi(self.dut)
+        self.dut.droid.telephonyToggleDataConnection(True)
 
     def setup_test(self):
         """Setup device."""
-        self.dut.unlock_screen()
-
-    def teardown_test(self):
-        """Reset to default state after each test."""
         wutils.reset_wifi(self.dut)
+        self.dut.unlock_screen()
+        self._go_to_wifi_settings()
 
     def on_fail(self, test_name, begin_time):
         self.dut.take_bug_report(test_name, begin_time)
 
     ### Helper methods ###
 
-    def _verify_captive_portal(self, network, uicd_workflow):
+    def _go_to_wifi_settings(self):
+        """Go to wifi settings to perform UI actions for Captive portal."""
+        self.dut.adb.shell("am start -a android.settings.SETTINGS")
+        asserts.assert_true(
+            uutils.has_element(self.dut, text="Network & internet"),
+            "Failed to find 'Network & internet' icon")
+        uutils.wait_and_click(self.dut, text="Network & internet")
+        uutils.wait_and_click(self.dut, text="Not connected")
+
+    def _verify_captive_portal(self, network, click_accept=ACCEPT_CONTINUE):
         """Connect to captive portal network using uicd workflow.
 
         Steps:
@@ -83,15 +86,16 @@
 
         Args:
             network: captive portal network to connect to
-            uicd_workflow: ui workflow to accept captive portal conn
+            click_accept: Notification to select to accept captive portal
         """
         # connect to captive portal wifi network
-        wutils.start_wifi_connection_scan_and_ensure_network_found(
-            self.dut, network[WifiEnums.SSID_KEY])
-        wutils.wifi_connect(self.dut, network, check_connectivity=False)
+        wutils.connect_to_wifi_network(
+            self.dut, network, check_connectivity=False)
 
-        # run uicd
-        self.ui.run(self.dut.serial, uicd_workflow)
+        # run ui automator
+        uutils.wait_and_click(self.dut, text="%s" % network["SSID"])
+        if uutils.has_element(self.dut, text="%s" % click_accept):
+            uutils.wait_and_click(self.dut, text="%s" % click_accept)
 
         # wait for sometime for captive portal connection to go through
         curr_time = time.time()
@@ -103,11 +107,9 @@
             time.sleep(2)
 
         # verify connectivity
-        try:
-            asserts.assert_true(wutils.validate_connection(self.dut),
-                                "Failed to verify internet connectivity")
-        except Exception as e:
-            asserts.fail("Failed to connect to captive portal: %s" % e)
+        asserts.assert_true(
+            wutils.validate_connection(self.dut, ping_gateway=False),
+            "Failed to connect to internet. Captive portal test failed")
 
     ### Test Cases ###
 
@@ -125,8 +127,7 @@
         cutils.set_private_dns(self.dut, cconst.PRIVATE_DNS_MODE_OPPORTUNISTIC)
 
         # verify connection to captive portal network
-        self._verify_captive_portal(self.rk_captive_portal,
-                                    self.rk_workflow_config)
+        self._verify_captive_portal(self.rk_captive_portal)
 
     @test_tracker_info(uuid="8ea18d80-0170-41b1-8945-fe14bcd4feab")
     @WifiBaseTest.wifi_test_wrap
@@ -142,8 +143,7 @@
         cutils.set_private_dns(self.dut, cconst.PRIVATE_DNS_MODE_OFF)
 
         # verify connection to captive portal network
-        self._verify_captive_portal(self.rk_captive_portal,
-                                    self.rk_workflow_config)
+        self._verify_captive_portal(self.rk_captive_portal)
 
     @test_tracker_info(uuid="e8e05907-55f7-40e5-850c-b3111ceb31a4")
     @WifiBaseTest.wifi_test_wrap
@@ -161,8 +161,7 @@
                                cconst.DNS_GOOGLE)
 
         # verify connection to captive portal network
-        self._verify_captive_portal(self.rk_captive_portal,
-                                    self.rk_workflow_config)
+        self._verify_captive_portal(self.rk_captive_portal)
 
     @test_tracker_info(uuid="76e49800-f141-4fd2-9969-562585eb1e7a")
     def test_guestgate_captive_portal_default(self):
@@ -177,7 +176,7 @@
         cutils.set_private_dns(self.dut, cconst.PRIVATE_DNS_MODE_OPPORTUNISTIC)
 
         # verify connection to captive portal network
-        self._verify_captive_portal(self.gg_captive_portal, "gg_captive_portal")
+        self._verify_captive_portal(self.gg_captive_portal)
 
     @test_tracker_info(uuid="0aea0cac-0f42-406b-84ba-62c1ef74adfc")
     def test_guestgate_captive_portal_private_dns_off(self):
@@ -192,7 +191,7 @@
         cutils.set_private_dns(self.dut, cconst.PRIVATE_DNS_MODE_OFF)
 
         # verify connection to captive portal network
-        self._verify_captive_portal(self.gg_captive_portal, "gg_captive_portal")
+        self._verify_captive_portal(self.gg_captive_portal)
 
     @test_tracker_info(uuid="39124dcc-2fd3-4d33-b129-a1c8150b7f2a")
     def test_guestgate_captive_portal_private_dns_strict(self):
@@ -209,4 +208,4 @@
                                cconst.DNS_GOOGLE)
 
         # verify connection to captive portal network
-        self._verify_captive_portal(self.gg_captive_portal, "gg_captive_portal")
+        self._verify_captive_portal(self.gg_captive_portal)
diff --git a/acts/tests/google/wifi/WifiPasspointTest.py b/acts/tests/google/wifi/WifiPasspointTest.py
index 962d9db..8e51eab 100755
--- a/acts/tests/google/wifi/WifiPasspointTest.py
+++ b/acts/tests/google/wifi/WifiPasspointTest.py
@@ -20,14 +20,13 @@
 import time
 
 import acts.base_test
+from acts.test_utils.net import ui_utils as uutils
 import acts.test_utils.wifi.wifi_test_utils as wutils
 
 
 import WifiManagerTest
 from acts import asserts
 from acts import signals
-from acts.libs.uicd.uicd_cli import UicdCli
-from acts.libs.uicd.uicd_cli import UicdError
 from acts.test_decorators import test_tracker_info
 from acts.test_utils.tel.tel_test_utils import get_operator_name
 from acts.utils import force_airplane_mode
@@ -49,6 +48,12 @@
 
 UNKNOWN_FQDN = "@#@@!00fffffx"
 
+# Constants for Boingo UI automator
+EDIT_TEXT_CLASS_NAME = "android.widget.EditText"
+PASSWORD_TEXT = "Password"
+PASSPOINT_BUTTON = "Get Passpoint"
+BOINGO_UI_TEXT = "Online Sign Up"
+
 class WifiPasspointTest(acts.base_test.BaseTestClass):
     """Tests for APIs in Android's WifiManager class.
 
@@ -61,19 +66,15 @@
     def setup_class(self):
         self.dut = self.android_devices[0]
         wutils.wifi_test_device_init(self.dut)
-        req_params = ["passpoint_networks", "uicd_workflows", "uicd_zip"]
-        opt_param = []
-        self.unpack_userparams(
-            req_param_names=req_params, opt_param_names=opt_param)
-        self.unpack_userparams(req_params)
+        req_params = ["passpoint_networks",
+                      "boingo_username",
+                      "boingo_password",]
+        self.unpack_userparams(req_param_names=req_params,)
         asserts.assert_true(
             len(self.passpoint_networks) > 0,
             "Need at least one Passpoint network.")
         wutils.wifi_toggle_state(self.dut, True)
         self.unknown_fqdn = UNKNOWN_FQDN
-        # Setup Uicd cli object for UI interation.
-        self.ui = UicdCli(self.uicd_zip[0], self.uicd_workflows)
-        self.passpoint_workflow = "passpoint-login_%s" % self.dut.model
 
 
     def setup_test(self):
@@ -146,6 +147,36 @@
             raise signals.TestFailure("Failed to delete Passpoint configuration"
                                       " with FQDN = %s" % passpoint_config[0])
 
+    def ui_automator_boingo(self):
+        """Run UI automator for boingo passpoint."""
+        # Verify the boingo login page shows
+        asserts.assert_true(
+            uutils.has_element(self.dut, text=BOINGO_UI_TEXT),
+            "Failed to launch boingohotspot login page")
+
+        # Go to the bottom of the page
+        for _ in range(3):
+            self.dut.adb.shell("input swipe 300 900 300 300")
+
+        # Enter username
+        uutils.wait_and_input_text(self.dut,
+                                   input_text=self.boingo_username,
+                                   text="",
+                                   class_name=EDIT_TEXT_CLASS_NAME)
+        self.dut.adb.shell("input keyevent 111")  # collapse keyboard
+        self.dut.adb.shell("input swipe 300 900 300 750")  # swipe up to show text
+
+        # Enter password
+        uutils.wait_and_input_text(self.dut,
+                                   input_text=self.boingo_password,
+                                   text=PASSWORD_TEXT)
+        self.dut.adb.shell("input keyevent 111")  # collapse keyboard
+        self.dut.adb.shell("input swipe 300 900 300 750")  # swipe up to show text
+
+        # Login
+        uutils.wait_and_click(self.dut, text=PASSPOINT_BUTTON)
+
+
     def start_subscription_provisioning(self, state):
         """Start subscription provisioning with a default provider."""
 
@@ -183,7 +214,7 @@
                     "Passpoint Provisioning status %s" % dut_event['data'][
                         'status'])
                 if int(dut_event['data']['status']) == 7:
-                    self.ui.run(self.dut.serial, self.passpoint_workflow)
+                    self.ui_automator_boingo()
         # Clear all previous events.
         self.dut.ed.clear_all_events()