Merge "Add test construct for RtNetlinkLinkMessage" into main
diff --git a/TEST_MAPPING b/TEST_MAPPING
index 4cf93a8..47e5d5e 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -267,6 +267,10 @@
     },
     {
       "name": "FrameworksNetTests"
+    },
+    // TODO: Move to presumit after meet SLO requirement.
+    {
+      "name": "NetworkStaticLibHostPythonTests"
     }
   ],
   "mainline-presubmit": [
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index cf67a82..e9f8858 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -61,3 +61,22 @@
         strict_updatability_linting: true,
     },
 }
+
+python_test_host {
+    name: "NetworkStaticLibHostPythonTests",
+    srcs: [
+        "host/python/*.py",
+    ],
+    main: "host/python/run_tests.py",
+    libs: [
+        "mobly",
+        "net-tests-utils-host-python-common",
+    ],
+    test_config: "host/python/test_config.xml",
+    test_suites: [
+        "general-tests",
+    ],
+    test_options: {
+        unit_test: true,
+    },
+}
diff --git a/staticlibs/tests/unit/host/python/adb_utils_test.py b/staticlibs/tests/unit/host/python/adb_utils_test.py
new file mode 100644
index 0000000..b75d464
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/adb_utils_test.py
@@ -0,0 +1,126 @@
+#  Copyright (C) 2024 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.
+
+import unittest
+from unittest.mock import MagicMock, patch
+from net_tests_utils.host.python import adb_utils
+from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError
+
+
+class TestAdbUtils(unittest.TestCase):
+
+  def setUp(self):
+    self.mock_ad = MagicMock()  # Mock Android device object
+    self.mock_ad.log = MagicMock()
+    self.mock_ad.adb.shell.return_value = b""  # Default empty return for shell
+
+  @patch(
+      "net_tests_utils.host.python.adb_utils.expect_dumpsys_state_with_retry"
+  )
+  @patch("net_tests_utils.host.python.adb_utils._set_screen_state")
+  def test_set_doze_mode_enable(
+      self, mock_set_screen_state, mock_expect_dumpsys_state
+  ):
+    adb_utils.set_doze_mode(self.mock_ad, True)
+    mock_set_screen_state.assert_called_once_with(self.mock_ad, False)
+
+  @patch(
+      "net_tests_utils.host.python.adb_utils.expect_dumpsys_state_with_retry"
+  )
+  def test_set_doze_mode_disable(self, mock_expect_dumpsys_state):
+    adb_utils.set_doze_mode(self.mock_ad, False)
+
+  @patch("net_tests_utils.host.python.adb_utils._get_screen_state")
+  def test_set_screen_state_success(self, mock_get_screen_state):
+    mock_get_screen_state.side_effect = [False, True]  # Simulate toggle
+    adb_utils._set_screen_state(self.mock_ad, True)
+
+  @patch("net_tests_utils.host.python.adb_utils._get_screen_state")
+  def test_set_screen_state_failure(self, mock_get_screen_state):
+    mock_get_screen_state.return_value = False  # State doesn't change
+    with self.assertRaises(UnexpectedBehaviorError):
+      adb_utils._set_screen_state(self.mock_ad, True)
+
+  @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
+  def test_get_screen_state(self, mock_get_value):
+    # Test cases for different return values of get_value_of_key_from_dumpsys
+    # TODO: Make it parameterized.
+    test_cases = [
+        ("Awake", True),
+        ("Asleep", False),
+        ("Dozing", False),
+        ("SomeOtherState", False),
+    ]
+
+    for state_str, expected_result in test_cases:
+      mock_get_value.return_value = state_str
+      self.assertEqual(
+          adb_utils._get_screen_state(self.mock_ad), expected_result
+      )
+
+  def test_get_value_of_key_from_dumpsys(self):
+    self.mock_ad.adb.shell.return_value = (
+        b"mWakefulness=Awake\nmOtherKey=SomeValue"
+    )
+    result = adb_utils.get_value_of_key_from_dumpsys(
+        self.mock_ad, "power", "mWakefulness"
+    )
+    self.assertEqual(result, "Awake")
+
+  @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
+  def test_expect_dumpsys_state_with_retry_success(self, mock_get_value):
+    # Test cases for different combinations of expected_state and get_value return value
+    # TODO: Make it parameterized.
+    test_cases = [
+        (True, ["true"]),  # Expect True, get True
+        (False, ["false"]),  # Expect False, get False
+        (
+            True,
+            ["false", "true"],
+        ),  # Expect True, get False which is unexpected, then get True
+        (
+            False,
+            ["true", "false"],
+        ),  # Expect False, get True which is unexpected, then get False
+    ]
+
+    for expected_state, returned_value in test_cases:
+      mock_get_value.side_effect = returned_value
+      # Verify the method returns and does not throw.
+      adb_utils.expect_dumpsys_state_with_retry(
+          self.mock_ad, "service", "key", expected_state, 0
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
+  def test_expect_dumpsys_state_with_retry_failure(self, mock_get_value):
+    mock_get_value.return_value = "false"
+    with self.assertRaises(UnexpectedBehaviorError):
+      adb_utils.expect_dumpsys_state_with_retry(
+          self.mock_ad, "service", "key", True, 0
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
+  def test_expect_dumpsys_state_with_retry_not_found(self, mock_get_value):
+    # Simulate the get_value_of_key_from_dumpsys cannot find the give key.
+    mock_get_value.return_value = None
+
+    # Expect the function to raise UnexpectedBehaviorError due to the exception
+    with self.assertRaises(UnexpectedBehaviorError):
+      adb_utils.expect_dumpsys_state_with_retry(
+          self.mock_ad, "service", "key", True
+      )
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
new file mode 100644
index 0000000..f7fb93b
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -0,0 +1,77 @@
+#  Copyright (C) 2024 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.
+
+import unittest
+from unittest.mock import MagicMock, patch
+from net_tests_utils.host.python.apf_utils import (
+    PatternNotFoundException,
+    get_apf_counter,
+    get_apf_counters_from_dumpsys,
+)
+
+
+class TestApfUtils(unittest.TestCase):
+
+  def setUp(self):
+    self.mock_ad = MagicMock()  # Mock Android device object
+
+  @patch("net_tests_utils.host.python.adb_utils.get_dumpsys_for_service")
+  def test_get_apf_counters_from_dumpsys_success(
+      self, mock_get_dumpsys: MagicMock
+  ) -> None:
+    mock_get_dumpsys.return_value = """
+IpClient.wlan0
+  APF packet counters:
+    COUNTER_NAME1: 123
+    COUNTER_NAME2: 456
+"""
+    iface_name = "wlan0"
+    counters = get_apf_counters_from_dumpsys(self.mock_ad, iface_name)
+    self.assertEqual(counters, {"COUNTER_NAME1": 123, "COUNTER_NAME2": 456})
+
+  @patch("net_tests_utils.host.python.adb_utils.get_dumpsys_for_service")
+  def test_get_apf_counters_from_dumpsys_exceptions(
+      self, mock_get_dumpsys: MagicMock
+  ) -> None:
+    test_cases = [
+        "",
+        "IpClient.wlan0\n",
+        "IpClient.wlan0\n APF packet counters:\n",
+        """
+IpClient.wlan1
+  APF packet counters:
+    COUNTER_NAME1: 123
+    COUNTER_NAME2: 456
+""",
+    ]
+
+    for dumpsys_output in test_cases:
+      mock_get_dumpsys.return_value = dumpsys_output
+      with self.assertRaises(PatternNotFoundException):
+        get_apf_counters_from_dumpsys(self.mock_ad, "wlan0")
+
+  @patch("net_tests_utils.host.python.apf_utils.get_apf_counters_from_dumpsys")
+  def test_get_apf_counter(self, mock_get_counters: MagicMock) -> None:
+    iface = "wlan0"
+    mock_get_counters.return_value = {
+        "COUNTER_NAME1": 123,
+        "COUNTER_NAME2": 456,
+    }
+    self.assertEqual(get_apf_counter(self.mock_ad, iface, "COUNTER_NAME1"), 123)
+    # Not found
+    self.assertEqual(get_apf_counter(self.mock_ad, iface, "COUNTER_NAME3"), 0)
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/staticlibs/tests/unit/host/python/assert_utils_test.py b/staticlibs/tests/unit/host/python/assert_utils_test.py
new file mode 100644
index 0000000..b970d14
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/assert_utils_test.py
@@ -0,0 +1,89 @@
+#  Copyright (C) 2024 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.
+
+import unittest
+from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError, expect_with_retry
+
+
+class TestAssertUtils(unittest.TestCase):
+
+  def test_predicate_succeed(self):
+    """Test when the predicate becomes True within retries."""
+    call_count = 0
+
+    def predicate():
+      nonlocal call_count
+      call_count += 1
+      return call_count > 2  # True on the third call
+
+    expect_with_retry(predicate, max_retries=5, retry_interval_sec=0)
+    self.assertEqual(call_count, 3)  # Ensure it was called exactly 3 times
+
+  def test_predicate_failed(self):
+    """Test when the predicate never becomes True."""
+
+    with self.assertRaises(UnexpectedBehaviorError):
+      expect_with_retry(
+          predicate=lambda: False, max_retries=3, retry_interval_sec=0
+      )
+
+  def test_retry_action_not_called_succeed(self):
+    """Test that the retry_action is not called if the predicate returns true in the first try."""
+    retry_action_called = False
+
+    def retry_action():
+      nonlocal retry_action_called
+      retry_action_called = True
+
+    expect_with_retry(
+        predicate=lambda: True,
+        retry_action=retry_action,
+        max_retries=5,
+        retry_interval_sec=0,
+    )
+    self.assertFalse(retry_action_called)  # Assert retry_action was NOT called
+
+  def test_retry_action_not_called_failed(self):
+    """Test that the retry_action is not called if the max_retries is reached."""
+    retry_action_called = False
+
+    def retry_action():
+      nonlocal retry_action_called
+      retry_action_called = True
+
+    with self.assertRaises(UnexpectedBehaviorError):
+      expect_with_retry(
+          predicate=lambda: False,
+          retry_action=retry_action,
+          max_retries=1,
+          retry_interval_sec=0,
+      )
+    self.assertFalse(retry_action_called)  # Assert retry_action was NOT called
+
+  def test_retry_action_called(self):
+    """Test that the retry_action is executed when provided."""
+    retry_action_called = False
+
+    def retry_action():
+      nonlocal retry_action_called
+      retry_action_called = True
+
+    with self.assertRaises(UnexpectedBehaviorError):
+      expect_with_retry(
+          predicate=lambda: False,
+          retry_action=retry_action,
+          max_retries=2,
+          retry_interval_sec=0,
+      )
+    self.assertTrue(retry_action_called)
diff --git a/staticlibs/tests/unit/host/python/run_tests.py b/staticlibs/tests/unit/host/python/run_tests.py
new file mode 100644
index 0000000..c44f1a8
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/run_tests.py
@@ -0,0 +1,27 @@
+#  Copyright (C) 2024 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.
+
+"""Main entrypoint for all of unittest."""
+
+import unittest
+
+# Import all unittest classes here, so it can be discovered by unittest module.
+# TODO: make the tests can be executed without manually import classes.
+from host.python.adb_utils_test import TestAdbUtils
+from host.python.apf_utils_test import TestApfUtils
+from host.python.assert_utils_test import TestAssertUtils
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/staticlibs/tests/unit/host/python/test_config.xml b/staticlibs/tests/unit/host/python/test_config.xml
new file mode 100644
index 0000000..26ee9e2
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/test_config.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2024 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 NetworkStaticLibHostPythonTests">
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+    <test class="com.android.tradefed.testtype.python.PythonBinaryHostTest">
+        <option name="par-file-name" value="NetworkStaticLibHostPythonTests" />
+        <option name="test-timeout" value="3m" />
+    </test>
+</configuration>
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 3843b90..8c71a91 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -86,8 +86,8 @@
 java_test_host {
     name: "net-tests-utils-host-common",
     srcs: [
-        "host/**/*.java",
-        "host/**/*.kt",
+        "host/java/**/*.java",
+        "host/java/**/*.kt",
     ],
     libs: ["tradefed"],
     test_suites: [
@@ -104,3 +104,11 @@
     ],
     data: [":ConnectivityTestPreparer"],
 }
+
+python_library_host {
+    name: "net-tests-utils-host-python-common",
+    srcs: [
+        "host/python/*.py",
+    ],
+    pkg_path: "net_tests_utils",
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
index 69fdbf8..8687ac7 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
@@ -90,25 +90,10 @@
         Modifier.isStatic(it.modifiers) &&
                 it.isAnnotationPresent(Parameterized.Parameters::class.java) }
 
-    override fun run(notifier: RunNotifier) {
-        if (baseRunner == null) {
-            // Report a single, skipped placeholder test for this class, as the class is expected to
-            // report results when run. In practice runners that apply the Filterable implementation
-            // would see a NoTestsRemainException and not call the run method.
-            notifier.fireTestIgnored(
-                    Description.createTestDescription(klass, "skippedClassForDevSdkMismatch"))
-            return
-        }
-        if (!shouldThreadLeakFailTest) {
-            baseRunner.run(notifier)
-            return
-        }
-
-        // Dump threads as a baseline to monitor thread leaks.
-        val threadCountsBeforeTest = getAllThreadNameCounts()
-
-        baseRunner.run(notifier)
-
+    private fun checkThreadLeak(
+            notifier: RunNotifier,
+            threadCountsBeforeTest: Map<String, Int>
+    ) {
         notifier.fireTestStarted(leakMonitorDesc)
         val threadCountsAfterTest = getAllThreadNameCounts()
         // TODO : move CompareOrUpdateResult to its own util instead of LinkProperties.
@@ -122,13 +107,39 @@
         val increasedThreads = threadsDiff.updated
                 .filter { threadCountsBeforeTest[it.key]!! < it.value }
         if (threadsDiff.added.isNotEmpty() || increasedThreads.isNotEmpty()) {
-            notifier.fireTestFailure(Failure(leakMonitorDesc,
-                    IllegalStateException("Unexpected thread changes: $threadsDiff")))
+            notifier.fireTestFailure(Failure(
+                    leakMonitorDesc,
+                    IllegalStateException("Unexpected thread changes: $threadsDiff")
+            ))
+        }
+        notifier.fireTestFinished(leakMonitorDesc)
+    }
+
+    override fun run(notifier: RunNotifier) {
+        if (baseRunner == null) {
+            // Report a single, skipped placeholder test for this class, as the class is expected to
+            // report results when run. In practice runners that apply the Filterable implementation
+            // would see a NoTestsRemainException and not call the run method.
+            notifier.fireTestIgnored(
+                    Description.createTestDescription(klass, "skippedClassForDevSdkMismatch")
+            )
+            return
+        }
+        val threadCountsBeforeTest = if (shouldThreadLeakFailTest) {
+            // Dump threads as a baseline to monitor thread leaks.
+            getAllThreadNameCounts()
+        } else {
+            null
+        }
+
+        baseRunner.run(notifier)
+
+        if (threadCountsBeforeTest != null) {
+            checkThreadLeak(notifier, threadCountsBeforeTest)
         }
         // Clears up internal state of all inline mocks.
         // TODO: Call clearInlineMocks() at the end of each test.
         Mockito.framework().clearInlineMocks()
-        notifier.fireTestFinished(leakMonitorDesc)
     }
 
     private fun getAllThreadNameCounts(): Map<String, Int> {
diff --git a/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
similarity index 100%
rename from staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
rename to staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
diff --git a/staticlibs/testutils/host/com/android/testutils/DisableConfigSyncTargetPreparer.kt b/staticlibs/testutils/host/java/com/android/testutils/DisableConfigSyncTargetPreparer.kt
similarity index 100%
rename from staticlibs/testutils/host/com/android/testutils/DisableConfigSyncTargetPreparer.kt
rename to staticlibs/testutils/host/java/com/android/testutils/DisableConfigSyncTargetPreparer.kt
diff --git a/staticlibs/testutils/host/python/adb_utils.py b/staticlibs/testutils/host/python/adb_utils.py
new file mode 100644
index 0000000..13c0646
--- /dev/null
+++ b/staticlibs/testutils/host/python/adb_utils.py
@@ -0,0 +1,118 @@
+#  Copyright (C) 2024 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.
+
+import re
+from mobly.controllers import android_device
+from net_tests_utils.host.python import assert_utils
+
+BYTE_DECODE_UTF_8 = "utf-8"
+
+
+def set_doze_mode(ad: android_device.AndroidDevice, enable: bool) -> None:
+  if enable:
+    adb_shell(ad, "cmd battery unplug")
+    expect_dumpsys_state_with_retry(
+        ad, "deviceidle", key="mCharging", expected_state=False
+    )
+    _set_screen_state(ad, False)
+    adb_shell(ad, "dumpsys deviceidle enable deep")
+    expect_dumpsys_state_with_retry(
+        ad, "deviceidle", key="mDeepEnabled", expected_state=True
+    )
+    adb_shell(ad, "dumpsys deviceidle force-idle deep")
+    expect_dumpsys_state_with_retry(
+        ad, "deviceidle", key="mForceIdle", expected_state=True
+    )
+  else:
+    adb_shell(ad, "cmd battery reset")
+    expect_dumpsys_state_with_retry(
+        ad, "deviceidle", key="mCharging", expected_state=True
+    )
+    adb_shell(ad, "dumpsys deviceidle unforce")
+    expect_dumpsys_state_with_retry(
+        ad, "deviceidle", key="mForceIdle", expected_state=False
+    )
+
+
+def _set_screen_state(
+    ad: android_device.AndroidDevice, target_state: bool
+) -> None:
+  assert_utils.expect_with_retry(
+      predicate=lambda: _get_screen_state(ad) == target_state,
+      retry_action=lambda: adb_shell(
+          ad, "input keyevent KEYCODE_POWER"
+      ),  # Toggle power key again when retry.
+  )
+
+
+def _get_screen_state(ad: android_device.AndroidDevice) -> bool:
+  return get_value_of_key_from_dumpsys(ad, "power", "mWakefulness") == "Awake"
+
+
+def get_value_of_key_from_dumpsys(
+    ad: android_device.AndroidDevice, service: str, key: str
+) -> str:
+  output = get_dumpsys_for_service(ad, service)
+  # Search for key=value pattern from the dumpsys output.
+  # e.g. mWakefulness=Awake
+  pattern = rf"{key}=(.*)"
+  # Only look for the first occurrence.
+  match = re.search(pattern, output)
+  if match:
+    ad.log.debug(
+        "Getting key-value from dumpsys: " + key + "=" + match.group(1)
+    )
+    return match.group(1)
+  else:
+    return None
+
+
+def expect_dumpsys_state_with_retry(
+    ad: android_device.AndroidDevice,
+    service: str,
+    key: str,
+    expected_state: bool,
+    retry_interval_sec: int = 1,
+) -> None:
+  def predicate():
+    value = get_value_of_key_from_dumpsys(ad, service, key)
+    if value is None:
+      return False
+    return value.lower() == str(expected_state).lower()
+
+  assert_utils.expect_with_retry(
+      predicate=predicate,
+      retry_interval_sec=retry_interval_sec,
+  )
+
+
+def get_dumpsys_for_service(
+    ad: android_device.AndroidDevice, service: str
+) -> str:
+  return adb_shell(ad, "dumpsys " + service)
+
+
+def adb_shell(ad: android_device.AndroidDevice, shell_cmd: str) -> str:
+  """Runs adb shell command.
+
+  Args:
+    ad: Android device object.
+    shell_cmd: string of list of strings, adb shell command.
+
+  Returns:
+    string, replies from adb shell command.
+  """
+  ad.log.debug("Executing adb shell %s", shell_cmd)
+  data = ad.adb.shell(shell_cmd)
+  return data.decode(BYTE_DECODE_UTF_8).strip()
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
new file mode 100644
index 0000000..331e970
--- /dev/null
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -0,0 +1,77 @@
+#  Copyright (C) 2024 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.
+
+import re
+from mobly.controllers import android_device
+from net_tests_utils.host.python import adb_utils
+
+
+class PatternNotFoundException(Exception):
+  """Raised when the given pattern cannot be found."""
+
+
+def get_apf_counter(
+    ad: android_device.AndroidDevice, iface: str, counter_name: str
+) -> int:
+  counters = get_apf_counters_from_dumpsys(ad, iface)
+  return counters.get(counter_name, 0)
+
+
+def get_apf_counters_from_dumpsys(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> dict:
+  dumpsys = adb_utils.get_dumpsys_for_service(ad, "network_stack")
+
+  # Extract IpClient section of the specified interface.
+  # This takes inputs like:
+  # IpClient.wlan0
+  #   ...
+  # IpClient.wlan1
+  #   ...
+  iface_pattern = re.compile(
+      r"^IpClient\." + iface_name + r"\n" + r"((^\s.*\n)+)", re.MULTILINE
+  )
+  iface_result = iface_pattern.search(dumpsys)
+  if iface_result is None:
+    raise PatternNotFoundException("Cannot find IpClient for " + iface_name)
+
+  # Extract APF counters section from IpClient section, which looks like:
+  #     APF packet counters:
+  #       COUNTER_NAME: VALUE
+  #       ....
+  apf_pattern = re.compile(
+      r"APF packet counters:.*\n.(\s+[A-Z_0-9]+: \d+\n)+", re.MULTILINE
+  )
+  apf_result = apf_pattern.search(iface_result.group(0))
+  if apf_result is None:
+    raise PatternNotFoundException(
+        "Cannot find APF counters in text: " + iface_result.group(0)
+    )
+
+  # Extract key-value pairs from APF counters section into a list of tuples,
+  # e.g. [('COUNTER1', '1'), ('COUNTER2', '2')].
+  counter_pattern = re.compile(r"(?P<name>[A-Z_0-9]+): (?P<value>\d+)")
+  counter_result = counter_pattern.findall(apf_result.group(0))
+  if counter_result is None:
+    raise PatternNotFoundException(
+        "Cannot extract APF counters in text: " + apf_result.group(0)
+    )
+
+  # Convert into a dict.
+  result = {}
+  for key, value_str in counter_result:
+    result[key] = int(value_str)
+
+  ad.log.debug("Getting apf counters: " + str(result))
+  return result
diff --git a/staticlibs/testutils/host/python/assert_utils.py b/staticlibs/testutils/host/python/assert_utils.py
new file mode 100644
index 0000000..da1bb9e
--- /dev/null
+++ b/staticlibs/testutils/host/python/assert_utils.py
@@ -0,0 +1,43 @@
+#  Copyright (C) 2024 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.
+
+import time
+from typing import Callable
+
+
+class UnexpectedBehaviorError(Exception):
+  """Raised when there is an unexpected behavior during applying a procedure."""
+
+
+def expect_with_retry(
+    predicate: Callable[[], bool],
+    retry_action: Callable[[], None] = None,
+    max_retries: int = 10,
+    retry_interval_sec: int = 1,
+) -> None:
+  """Executes a predicate and retries if it doesn't return True."""
+
+  for retry in range(max_retries):
+    if predicate():
+      return None
+    else:
+      if retry == max_retries - 1:
+        break
+      if retry_action:
+        retry_action()
+      time.sleep(retry_interval_sec)
+
+  raise UnexpectedBehaviorError(
+      "Predicate didn't become true after " + str(max_retries) + " retries."
+  )
diff --git a/tests/cts/multidevices/utils/mdns_utils.py b/staticlibs/testutils/host/python/mdns_utils.py
similarity index 100%
rename from tests/cts/multidevices/utils/mdns_utils.py
rename to staticlibs/testutils/host/python/mdns_utils.py
diff --git a/tests/cts/multidevices/utils/tether_utils.py b/staticlibs/testutils/host/python/tether_utils.py
similarity index 100%
rename from tests/cts/multidevices/utils/tether_utils.py
rename to staticlibs/testutils/host/python/tether_utils.py
diff --git a/tests/cts/multidevices/Android.bp b/tests/cts/multidevices/Android.bp
index 1d30d68..dc90adb 100644
--- a/tests/cts/multidevices/Android.bp
+++ b/tests/cts/multidevices/Android.bp
@@ -22,10 +22,10 @@
     main: "connectivity_multi_devices_test.py",
     srcs: [
         "connectivity_multi_devices_test.py",
-        "utils/*.py",
     ],
     libs: [
         "mobly",
+        "net-tests-utils-host-python-common",
     ],
     test_suites: [
         "cts",
diff --git a/tests/cts/multidevices/connectivity_multi_devices_test.py b/tests/cts/multidevices/connectivity_multi_devices_test.py
index abd6fe2..840831b 100644
--- a/tests/cts/multidevices/connectivity_multi_devices_test.py
+++ b/tests/cts/multidevices/connectivity_multi_devices_test.py
@@ -5,9 +5,8 @@
 from mobly import test_runner
 from mobly import utils
 from mobly.controllers import android_device
-from utils import mdns_utils
-from utils import tether_utils
-from utils.tether_utils import UpstreamType
+from net_tests_utils.host.python import mdns_utils, tether_utils
+from net_tests_utils.host.python.tether_utils import UpstreamType
 
 CONNECTIVITY_MULTI_DEVICES_SNIPPET_PACKAGE = "com.google.snippet.connectivity"
 
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
index f6cbeeb..5662fca 100644
--- a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -27,10 +27,17 @@
 import android.net.NetworkRequest
 import android.net.apf.ApfCapabilities
 import android.net.apf.ApfConstants.ETH_ETHERTYPE_OFFSET
+import android.net.apf.ApfConstants.ETH_HEADER_LEN
+import android.net.apf.ApfConstants.ICMP6_CHECKSUM_OFFSET
 import android.net.apf.ApfConstants.ICMP6_TYPE_OFFSET
+import android.net.apf.ApfConstants.IPV6_DEST_ADDR_OFFSET
+import android.net.apf.ApfConstants.IPV6_HEADER_LEN
 import android.net.apf.ApfConstants.IPV6_NEXT_HEADER_OFFSET
+import android.net.apf.ApfConstants.IPV6_SRC_ADDR_OFFSET
 import android.net.apf.ApfCounterTracker
+import android.net.apf.ApfCounterTracker.Counter.DROPPED_IPV6_MULTICAST_PING
 import android.net.apf.ApfCounterTracker.Counter.FILTER_AGE_16384THS
+import android.net.apf.ApfCounterTracker.Counter.PASSED_IPV6_ICMP
 import android.net.apf.ApfV4Generator
 import android.net.apf.ApfV4GeneratorBase
 import android.net.apf.ApfV6Generator
@@ -61,6 +68,12 @@
 import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
 import com.android.compatibility.common.util.VsrTest
 import com.android.internal.util.HexDump
+import com.android.net.module.util.NetworkStackConstants.ETHER_ADDR_LEN
+import com.android.net.module.util.NetworkStackConstants.ETHER_DST_ADDR_OFFSET
+import com.android.net.module.util.NetworkStackConstants.ETHER_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.ETHER_SRC_ADDR_OFFSET
+import com.android.net.module.util.NetworkStackConstants.ICMPV6_HEADER_MIN_LEN
+import com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_LEN
 import com.android.net.module.util.PacketReader
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
@@ -216,8 +229,8 @@
             Os.sendto(sockFd!!, packet, 0, packet.size, 0, PING_DESTINATION)
         }
 
-        fun expectPingReply(): ByteArray {
-            return futureReply!!.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
+        fun expectPingReply(timeoutMs: Long = TIMEOUT_MS): ByteArray {
+            return futureReply!!.get(timeoutMs, TimeUnit.MILLISECONDS)
         }
 
         fun expectPingDropped() {
@@ -622,4 +635,103 @@
         assertThat(timeDiff).isGreaterThan(timeDiffLowerBound)
         assertThat(timeDiff).isLessThan(timeDiffUpperBound)
     }
+
+    @VsrTest(
+            requirements = ["VSR-5.3.12-002", "VSR-5.3.12-005", "VSR-5.3.12-012", "VSR-5.3.12-013",
+                "VSR-5.3.12-014", "VSR-5.3.12-015", "VSR-5.3.12-016", "VSR-5.3.12-017"]
+    )
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun testReplyPing() {
+        assumeApfVersionSupportAtLeast(6000)
+        installProgram(ByteArray(caps.maximumApfProgramSize) { 0 }) // Clear previous program
+        readProgram() // Ensure installation is complete
+
+        val payloadSize = 56
+        val payload = ByteArray(payloadSize).also { Random.nextBytes(it) }
+        val firstByte = payload.take(1).toByteArray()
+
+        val pingRequestIpv6PayloadLen = PING_HEADER_LENGTH + 1
+        val pingRequestPktLen = ETH_HEADER_LEN + IPV6_HEADER_LEN + pingRequestIpv6PayloadLen
+
+        val gen = ApfV6Generator(
+                caps.apfVersionSupported,
+                caps.maximumApfProgramSize,
+                caps.maximumApfProgramSize
+        )
+        val skipPacketLabel = gen.uniqueLabel
+
+        // Summary of the program:
+        //   if the packet is not ICMPv6 echo reply
+        //     pass
+        //   else if the echo reply payload size is 1
+        //     increase PASSED_IPV6_ICMP counter
+        //     pass
+        //   else
+        //     transmit a ICMPv6 echo request packet with the first byte of the payload in the reply
+        //     increase DROPPED_IPV6_MULTICAST_PING counter
+        //     drop
+        val program = gen
+                .addLoad16(R0, ETH_ETHERTYPE_OFFSET)
+                .addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), skipPacketLabel)
+                .addLoad8(R0, IPV6_NEXT_HEADER_OFFSET)
+                .addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), skipPacketLabel)
+                .addLoad8(R0, ICMP6_TYPE_OFFSET)
+                .addJumpIfR0NotEquals(0x81, skipPacketLabel) // Echo reply type
+                .addLoadFromMemory(R0, MemorySlot.PACKET_SIZE)
+                .addCountAndPassIfR0Equals(
+                        (ETHER_HEADER_LEN + IPV6_HEADER_LEN + PING_HEADER_LENGTH + firstByte.size)
+                                .toLong(),
+                        PASSED_IPV6_ICMP
+                )
+                // Ping Packet Generation
+                .addAllocate(pingRequestPktLen)
+                // Eth header
+                .addPacketCopy(ETHER_SRC_ADDR_OFFSET, ETHER_ADDR_LEN) // dst MAC address
+                .addPacketCopy(ETHER_DST_ADDR_OFFSET, ETHER_ADDR_LEN) // src MAC address
+                .addWriteU16(ETH_P_IPV6) // IPv6 type
+                // IPv6 Header
+                .addWrite32(0x60000000) // IPv6 Header: version, traffic class, flowlabel
+                // payload length (2 bytes) | next header: ICMPv6 (1 byte) | hop limit (1 byte)
+                .addWrite32(pingRequestIpv6PayloadLen shl 16 or (IPPROTO_ICMPV6 shl 8 or 64))
+                .addPacketCopy(IPV6_DEST_ADDR_OFFSET, IPV6_ADDR_LEN) // src ip
+                .addPacketCopy(IPV6_SRC_ADDR_OFFSET, IPV6_ADDR_LEN) // dst ip
+                // ICMPv6
+                .addWriteU8(0x80) // type: echo request
+                .addWriteU8(0) // code
+                .addWriteU16(pingRequestIpv6PayloadLen) // checksum
+                // identifier
+                .addPacketCopy(ETHER_HEADER_LEN + IPV6_HEADER_LEN + ICMPV6_HEADER_MIN_LEN, 2)
+                .addWriteU16(0) // sequence number
+                .addDataCopy(firstByte) // data
+                .addTransmitL4(
+                        ETHER_HEADER_LEN, // ip_ofs
+                        ICMP6_CHECKSUM_OFFSET, // csum_ofs
+                        IPV6_SRC_ADDR_OFFSET, // csum_start
+                        IPPROTO_ICMPV6, // partial_sum
+                        false // udp
+                )
+                // Warning: the program abuse DROPPED_IPV6_MULTICAST_PING for debugging purpose
+                .addCountAndDrop(DROPPED_IPV6_MULTICAST_PING)
+                .defineLabel(skipPacketLabel)
+                .addPass()
+                .generate()
+
+        installProgram(program)
+        readProgram() // Ensure installation is complete
+
+        packetReader.sendPing(payload, payloadSize)
+
+        val replyPayload = try {
+            packetReader.expectPingReply(TIMEOUT_MS * 2)
+        } catch (e: TimeoutException) {
+            byteArrayOf() // Empty payload if timeout occurs
+        }
+
+        val apfCounterTracker = ApfCounterTracker()
+        apfCounterTracker.updateCountersFromData(readProgram())
+        Log.i(TAG, "counter map: ${apfCounterTracker.counters}")
+
+        assertThat(replyPayload).isEqualTo(firstByte)
+    }
 }