Merge "Create the first Nearby Mainline e2e test: SeekerDiscoverProviderTest." into tm-dev
diff --git a/nearby/tests/multidevices/host/Android.bp b/nearby/tests/multidevices/host/Android.bp
new file mode 100644
index 0000000..b0adfba
--- /dev/null
+++ b/nearby/tests/multidevices/host/Android.bp
@@ -0,0 +1,42 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// Run the tests: atest -v CtsSeekerDiscoverProviderTest
+python_test_host {
+    name: "CtsSeekerDiscoverProviderTest",
+    main: "seeker_discover_provider_test.py",
+    srcs: ["seeker_discover_provider_test.py"],
+    libs: ["NearbyMultiDevicesHostHelper"],
+    test_suites: [
+        "cts",
+        "general-tests",
+    ],
+    test_options: {
+        unit_test: false,
+    },
+    data: [
+        // Package the snippet with the Mobly test
+        ":NearbyMultiDevicesClientsSnippets",
+    ],
+}
+
+python_library_host {
+    name: "NearbyMultiDevicesHostHelper",
+    srcs: ["*.py"],
+    exclude_srcs: ["*_test.py"],
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/host/AndroidTest.xml b/nearby/tests/multidevices/host/AndroidTest.xml
new file mode 100644
index 0000000..cca0826
--- /dev/null
+++ b/nearby/tests/multidevices/host/AndroidTest.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2021 The Android Open Source Project
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+          http://www.apache.org/licenses/LICENSE-2.0
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+<configuration description="Config for CTS seeker scan provider test">
+    <option name="test-suite-tag" value="cts" />
+    <option name="config-descriptor:metadata" key="component" value="wifi" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_secondary_user" />
+
+    <device name="device1">
+        <!-- For coverage to work, the APK should not be uninstalled until after coverage is pulled.
+             So it's a lot easier to install APKs outside the python code.
+        -->
+        <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+            <option name="test-file-name" value="NearbyMultiDevicesClientsSnippets.apk" />
+            <option name="check-min-sdk" value="true" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+            <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+            <option name="run-command" value="wm dismiss-keyguard" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.PythonVirtualenvPreparer">
+          <!-- Any python dependencies can be specified and will be installed with pip -->
+          <option name="dep-module" value="mobly" />
+          <option name="dep-module" value="retry" />
+        </target_preparer>
+    </device>
+    <device name="device2">
+        <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+            <option name="test-file-name" value="NearbyMultiDevicesClientsSnippets.apk" />
+            <option name="check-min-sdk" value="true" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+            <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+            <option name="run-command" value="wm dismiss-keyguard" />
+        </target_preparer>
+    </device>
+
+    <test class="com.android.tradefed.testtype.mobly.MoblyBinaryHostTest">
+      <!-- The mobly-par-file-name should match the module name -->
+      <option name="mobly-par-file-name" value="CtsSeekerDiscoverProviderTest" />
+      <!-- Timeout limit in milliseconds for all test cases of the python binary -->
+      <option name="mobly-test-timeout" value="60000" />
+    </test>
+</configuration>
+
diff --git a/nearby/tests/multidevices/host/event_helper.py b/nearby/tests/multidevices/host/event_helper.py
new file mode 100644
index 0000000..a642246
--- /dev/null
+++ b/nearby/tests/multidevices/host/event_helper.py
@@ -0,0 +1,55 @@
+"""This is a shared library to help handling Mobly event waiting logic."""
+
+import time
+from typing import Callable
+
+from mobly.controllers.android_device_lib import callback_handler
+from mobly.controllers.android_device_lib import snippet_event
+
+# Abbreviations for common use type
+CallbackHandler = callback_handler.CallbackHandler
+SnippetEvent = snippet_event.SnippetEvent
+
+# Type definition for the callback functions to make code formatted nicely
+OnReceivedCallback = Callable[[SnippetEvent, int], bool]
+OnWaitingCallback = Callable[[int], None]
+OnMissedCallback = Callable[[], None]
+
+
+def wait_callback_event(callback_event_handler: CallbackHandler,
+                        event_name: str, timeout_seconds: int,
+                        on_received: OnReceivedCallback,
+                        on_waiting: OnWaitingCallback,
+                        on_missed: OnMissedCallback) -> None:
+    """Waits until the matched event has been received or timeout.
+
+    Here we keep waitAndGet for event callback from EventSnippet.
+    We loop until over timeout_seconds instead of directly
+    waitAndGet(timeout=teardown_timeout_seconds). Because there is
+    MAX_TIMEOUT limitation in callback_handler of Mobly.
+
+    Args:
+      callback_event_handler: Mobly callback events handler.
+      event_name: the specific name of the event to wait.
+      timeout_seconds: the number of seconds to wait before giving up.
+      on_received: calls when event received, return false to keep waiting.
+      on_waiting: calls when waitAndGet timeout.
+      on_missed: calls when giving up.
+    """
+    start_time = time.perf_counter()
+    deadline = start_time + timeout_seconds
+    while time.perf_counter() < deadline:
+        remaining_time_sec = min(callback_handler.DEFAULT_TIMEOUT,
+                                 deadline - time.perf_counter())
+        try:
+            event = callback_event_handler.waitAndGet(
+                event_name, timeout=remaining_time_sec)
+        except callback_handler.TimeoutError:
+            elapsed_time = int(time.perf_counter() - start_time)
+            on_waiting(elapsed_time)
+        else:
+            elapsed_time = int(time.perf_counter() - start_time)
+            if on_received(event, elapsed_time):
+                break
+    else:
+        on_missed()
diff --git a/nearby/tests/multidevices/host/fast_pair_provider_simulator.py b/nearby/tests/multidevices/host/fast_pair_provider_simulator.py
new file mode 100644
index 0000000..1f62dfb
--- /dev/null
+++ b/nearby/tests/multidevices/host/fast_pair_provider_simulator.py
@@ -0,0 +1,133 @@
+"""Fast Pair provider simulator role."""
+
+from mobly import asserts
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import snippet_event
+import retry
+
+import event_helper
+
+# The package name of the provider simulator snippet.
+FP_PROVIDER_SIMULATOR_SNIPPETS_PACKAGE = 'android.nearby.multidevices'
+
+# Events reported from the provider simulator snippet.
+ON_SCAN_MODE_CHANGE_EVENT = 'onScanModeChange'
+ON_ADVERTISING_CHANGE_EVENT = 'onAdvertisingChange'
+
+# Target scan mode.
+DISCOVERABLE_MODE = 'DISCOVERABLE'
+
+# Abbreviations for common use type.
+AndroidDevice = android_device.AndroidDevice
+SnippetEvent = snippet_event.SnippetEvent
+wait_for_event = event_helper.wait_callback_event
+
+
+class FastPairProviderSimulator:
+    """A proxy for provider simulator snippet on the device."""
+
+    def __init__(self, ad: AndroidDevice) -> None:
+        self._ad = ad
+        self._provider_status_callback = None
+
+    def load_snippet(self) -> None:
+        """Starts the provider simulator snippet and connects.
+
+        Raises:
+          SnippetError: Illegal load operations are attempted.
+        """
+        self._ad.load_snippet(
+            name='fp', package=FP_PROVIDER_SIMULATOR_SNIPPETS_PACKAGE)
+
+    def start_provider_simulator(self, model_id: str,
+                                 anti_spoofing_key: str) -> None:
+        """Starts the Fast Pair provider simulator.
+
+        Args:
+          model_id: A 3-byte hex string for seeker side to recognize the device (ex:
+            0x00000C).
+          anti_spoofing_key: A public key for registered headsets.
+        """
+        self._provider_status_callback = self._ad.fp.startProviderSimulator(
+            model_id, anti_spoofing_key)
+
+    def stop_provider_simulator(self) -> None:
+        """Stops the Fast Pair provider simulator."""
+        self._ad.fp.stopProviderSimulator()
+
+    @retry.retry(tries=3)
+    def get_ble_mac_address(self) -> str:
+        """Gets Bluetooth low energy mac address of the provider simulator.
+
+        The BLE mac address will be set by the AdvertisingSet.getOwnAddress()
+        callback. This is the callback flow in the custom Android build. It takes
+        a while after advertising started so we use retry here to wait it.
+
+        Returns:
+          The BLE mac address of the Fast Pair provider simulator.
+        """
+        return self._ad.fp.getBluetoothLeAddress()
+
+    def wait_for_discoverable_mode(self, timeout_seconds: int) -> None:
+        """Waits onScanModeChange event to ensure provider is discoverable.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+        """
+
+        def _on_scan_mode_change_event_received(
+                scan_mode_change_event: SnippetEvent, elapsed_time: int) -> bool:
+            scan_mode = scan_mode_change_event.data['mode']
+            self._ad.log.info(
+                'Provider simulator changed the scan mode to %s in %d seconds.',
+                scan_mode, elapsed_time)
+            return scan_mode == DISCOVERABLE_MODE
+
+        def _on_scan_mode_change_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from provider side '
+                'after %d seconds...', ON_SCAN_MODE_CHANGE_EVENT, elapsed_time)
+
+        def _on_scan_mode_change_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_SCAN_MODE_CHANGE_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=self._provider_status_callback,
+            event_name=ON_SCAN_MODE_CHANGE_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_scan_mode_change_event_received,
+            on_waiting=_on_scan_mode_change_event_waiting,
+            on_missed=_on_scan_mode_change_event_missed)
+
+    def wait_for_advertising_start(self, timeout_seconds: int) -> None:
+        """Waits onAdvertisingChange event to ensure provider is advertising.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+        """
+
+        def _on_advertising_mode_change_event_received(
+                scan_mode_change_event: SnippetEvent, elapsed_time: int) -> bool:
+            advertising_mode = scan_mode_change_event.data['isAdvertising']
+            self._ad.log.info(
+                'Provider simulator changed the advertising mode to %s in %d seconds.',
+                advertising_mode, elapsed_time)
+            return advertising_mode
+
+        def _on_advertising_mode_change_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from provider side '
+                'after %d seconds...', ON_ADVERTISING_CHANGE_EVENT, elapsed_time)
+
+        def _on_advertising_mode_change_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_ADVERTISING_CHANGE_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=self._provider_status_callback,
+            event_name=ON_ADVERTISING_CHANGE_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_advertising_mode_change_event_received,
+            on_waiting=_on_advertising_mode_change_event_waiting,
+            on_missed=_on_advertising_mode_change_event_missed)
diff --git a/nearby/tests/multidevices/host/fast_pair_seeker.py b/nearby/tests/multidevices/host/fast_pair_seeker.py
new file mode 100644
index 0000000..df02f51
--- /dev/null
+++ b/nearby/tests/multidevices/host/fast_pair_seeker.py
@@ -0,0 +1,91 @@
+"""Fast Pair seeker role."""
+
+from mobly import asserts
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import snippet_event
+
+import event_helper
+
+# The package name of the Nearby Mainline Fast Pair seeker Mobly snippet.
+FP_SEEKER_SNIPPETS_PACKAGE = 'android.nearby.multidevices'
+
+# Events reported from the seeker snippet.
+ON_PROVIDER_FOUND_EVENT = 'onDiscovered'
+
+# Abbreviations for common use type.
+AndroidDevice = android_device.AndroidDevice
+SnippetEvent = snippet_event.SnippetEvent
+wait_for_event = event_helper.wait_callback_event
+
+
+class FastPairSeeker:
+    """A proxy for seeker snippet on the device."""
+
+    def __init__(self, ad: AndroidDevice) -> None:
+        self._ad = ad
+        self._scan_result_callback = None
+
+    def load_snippet(self) -> None:
+        """Starts the seeker snippet and connects.
+
+        Raises:
+          SnippetError: Illegal load operations are attempted.
+        """
+        self._ad.load_snippet(name='fp', package=FP_SEEKER_SNIPPETS_PACKAGE)
+
+    def start_scan(self) -> None:
+        """Starts scanning to find Fast Pair provider devices."""
+        self._scan_result_callback = self._ad.fp.startScan()
+
+    def stop_scan(self) -> None:
+        """Stops the Fast Pair seeker scanning."""
+        self._ad.fp.stopScan()
+
+    def start_pair(self, model_id: str, address: str) -> None:
+        """Starts the Fast Pair seeker pairing.
+
+        Args:
+          model_id: A 3-byte hex string for seeker side to recognize the provider
+            device (ex: 0x00000C).
+          address: The BLE mac address of the Fast Pair provider.
+        """
+        self._ad.log.info('Before calling startPairing')
+        self._ad.fp.startPairing(model_id, address)
+        self._ad.log.info('After calling startPairing')
+
+    def wait_and_assert_provider_found(self, timeout_seconds: int,
+                                       expected_model_id: str,
+                                       expected_ble_mac_address: str) -> None:
+        """Waits and asserts any onDiscovered event from the seeker.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+          expected_model_id: The expected model ID of the remote Fast Pair provider
+            device.
+          expected_ble_mac_address: The expected BLE MAC address of the remote Fast
+            Pair provider device.
+        """
+
+        def _on_provider_found_event_received(provider_found_event: SnippetEvent,
+                                              elapsed_time: int) -> bool:
+            nearby_device_str = provider_found_event.data['device']
+            self._ad.log.info('Seeker discovered first provider(%s) in %d seconds.',
+                              nearby_device_str, elapsed_time)
+            return expected_ble_mac_address in nearby_device_str
+
+        def _on_provider_found_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from seeker side '
+                'after %d seconds...', ON_PROVIDER_FOUND_EVENT, elapsed_time)
+
+        def _on_provider_found_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_PROVIDER_FOUND_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=self._scan_result_callback,
+            event_name=ON_PROVIDER_FOUND_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_provider_found_event_received,
+            on_waiting=_on_provider_found_event_waiting,
+            on_missed=_on_provider_found_event_missed)
diff --git a/nearby/tests/multidevices/host/seeker_discover_provider_test.py b/nearby/tests/multidevices/host/seeker_discover_provider_test.py
new file mode 100644
index 0000000..f875250
--- /dev/null
+++ b/nearby/tests/multidevices/host/seeker_discover_provider_test.py
@@ -0,0 +1,76 @@
+# Lint as: python3
+"""CTS-V Nearby Mainline Fast Pair end-to-end test case: seeker can discover the provider."""
+
+import logging
+import sys
+
+from mobly import asserts
+from mobly import base_test
+from mobly import test_runner
+from mobly.controllers import android_device
+
+import fast_pair_provider_simulator
+import fast_pair_seeker
+
+# Default model ID to simulate on provider side.
+DEFAULT_MODEL_ID = '00000C'
+# Default public key to simulate as registered headsets.
+DEFAULT_ANTI_SPOOFING_KEY = 'Cbj9eCJrTdDgSYxLkqtfADQi86vIaMvxJsQ298sZYWE='
+# Default time in seconds for events waiting.
+DEFAULT_TIMEOUT_SEC = 60
+
+# Abbreviations for common use type.
+FastPairProviderSimulator = fast_pair_provider_simulator.FastPairProviderSimulator
+FastPairSeeker = fast_pair_seeker.FastPairSeeker
+
+
+class SeekerDiscoverProviderTest(base_test.BaseTestClass):
+    """Fast Pair seeker discover provider test."""
+
+    _provider: FastPairProviderSimulator
+    _seeker: FastPairSeeker
+
+    def setup_class(self) -> None:
+        super().setup_class()
+        self.duts = self.register_controller(android_device)
+
+        # Assume the 1st phone is provider, the 2nd is seeker.
+        provider_ad, seeker_ad = self.duts
+        provider_ad.debug_tag = 'FastPairProviderSimulator'
+        seeker_ad.debug_tag = 'MainlineFastPairSeeker'
+        self._provider = FastPairProviderSimulator(provider_ad)
+        self._seeker = FastPairSeeker(seeker_ad)
+        self._provider.load_snippet()
+        self._seeker.load_snippet()
+
+    def setup_test(self) -> None:
+        super().setup_test()
+        self._provider.start_provider_simulator(DEFAULT_MODEL_ID,
+                                                DEFAULT_ANTI_SPOOFING_KEY)
+        self._provider.wait_for_discoverable_mode(DEFAULT_TIMEOUT_SEC)
+        self._provider.wait_for_advertising_start(DEFAULT_TIMEOUT_SEC)
+        self._seeker.start_scan()
+
+    def teardown_test(self) -> None:
+        super().teardown_test()
+        self._seeker.stop_scan()
+        self._provider.stop_provider_simulator()
+        # Create per-test excepts of logcat.
+        for dut in self.duts:
+            dut.services.create_output_excerpts_all(self.current_test_info)
+
+    def test_seeker_start_scanning_find_provider(self) -> None:
+        provider_ble_mac_address = self._provider.get_ble_mac_address()
+        self._seeker.wait_and_assert_provider_found(
+            timeout_seconds=DEFAULT_TIMEOUT_SEC,
+            expected_model_id=DEFAULT_MODEL_ID,
+            expected_ble_mac_address=provider_ble_mac_address)
+
+
+if __name__ == '__main__':
+    # Take test args
+    index = sys.argv.index('--')
+    sys.argv = sys.argv[:1] + sys.argv[index + 1:]
+
+    logging.basicConfig(filename="/tmp/seeker_scan_provider_test_log.txt", level=logging.INFO)
+    test_runner.main()