No public description

PiperOrigin-RevId: 568108467
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6ec8596
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+# Google Home UI Automator
+
+Google Home Automator can help you automating your Google Home App.
+
+## Getting Started
+
+### Prerequisites
+
+#### Python 3
+
+You need a python 3 environment to run the script.
+
+#### Android phone
+
+1.  turn on `User Debugging` mode on your android phone.
+1.  connect your android phone to computer.
+
+#### Google Home App
+
+1.  You need to install Google Home App on your Android phone.
+1.  Login to your Google Home App.
+1.  Make sure the Google Home App's version is between `3.1.1.14` and `3.5.1.4`.
+
+### Installation
+
+1.  clone this repo.
+1.  `cd` to the folder.
+
+## Usage
+
+### Commissioning Matter device
+
+1.  run script `mobly-manager --commission m5stack,34970112332,Office`
+    -   the parameters are
+    *   desired Matter device
+    *   pairing code of your Matter device
+    *   room that is going to be assigned
+
+## Roadmap
+
+-   [x] Commissioning Matter device
+
+## Notice
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/__init__.py
diff --git a/errors.py b/errors.py
new file mode 100644
index 0000000..56ecc3c
--- /dev/null
+++ b/errors.py
@@ -0,0 +1,24 @@
+"""Module for UI Automator related errors.
+
+The error subclasses are intended to make it easier to distinguish between and
+handle different types of error exceptions.
+"""
+DEFAULT_ERROR_CODE = 0
+
+
+class NoAndroidDeviceError(Exception):
+  """Raised when no Android device connected to the host computer."""
+
+  err_code = 1
+
+
+class AndroidDeviceNotReadyError(Exception):
+  """Raised when a Android device is not ready to be controlled."""
+
+  err_code = 2
+
+
+class AdbError(Exception):
+  """Raised when adb command fails."""
+
+  err_code = 3
diff --git a/ui_automator.py b/ui_automator.py
new file mode 100644
index 0000000..88148f7
--- /dev/null
+++ b/ui_automator.py
@@ -0,0 +1,134 @@
+"""UI Automator controller in python layer.
+
+A python controller that can trigger mobly UI automator snippet to achieve some
+automated UI operations on Android phones.
+"""
+import logging
+import os
+
+from mobly import utils
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import adb
+from mobly.snippet import errors as snippet_errors
+
+from ui_automator import errors
+
+_MOBLY_SNIPPET_APK: str = 'com.chip.interop.moblysnippet'
+_MOBLY_SNIPPET_APK_NAME: str = 'mbs'
+
+
+class UIAutomator:
+  """UI Automator controller in python layer."""
+
+  def __init__(self, logger: logging.Logger = logging.getLogger()):
+    """Inits UIAutomator.
+
+    Interacts with android device by pre-installed apk. Can perform
+    methods provided by a snippet installed on the android device.
+
+    Args:
+        logger: Injected logger, if None, specify the default(root) one.
+    """
+    self._logger = logger
+    self._connected_device: android_device.AndroidDevice | None = None
+
+  def load_device(self):
+    """Selects the first connected android device.
+
+    Raises:
+        NoAndroidDeviceError: When no device is connected.
+    """
+    try:
+      android_devices = android_device.get_all_instances()
+    except adb.AdbError as exc:
+      raise errors.AdbError(
+          'Please install adb and add it to your PATH environment variable.'
+      ) from exc
+
+    if not android_devices:
+      raise errors.NoAndroidDeviceError(
+          'No Android device connected to the host computer.'
+      )
+    self._connected_device = android_devices[0]
+    self._logger.info(f'connected device: [{self._connected_device}]')
+
+  def load_snippet(self):
+    """Loads needed mobly snippet installed on android device.
+
+    Raises:
+        NoAndroidDeviceError: When no device is connected.
+        AndroidDeviceNotReadyError: When required snippet apk can not be loaded
+        on device.
+    """
+    if not self._connected_device:
+      raise errors.NoAndroidDeviceError(
+          'No Android device connected to the host computer.'
+      )
+
+    if not self._is_apk_installed(self._connected_device, _MOBLY_SNIPPET_APK):
+      self._install_apk(self._connected_device, self._get_mbs_apk_path())
+
+    try:
+      self._connected_device.load_snippet(
+          _MOBLY_SNIPPET_APK_NAME, _MOBLY_SNIPPET_APK
+      )
+    except snippet_errors.ServerStartPreCheckError as exc:
+      # Raises when apk not be installed or instrumented on device.
+      raise errors.AndroidDeviceNotReadyError(
+          f"Check device({self._connected_device.device_info['serial']}) has"
+          ' installed required apk.'
+      ) from exc
+    except (
+        snippet_errors.ServerStartError,
+        snippet_errors.ProtocolError,
+    ) as exc:
+      raise errors.AndroidDeviceNotReadyError(
+          f"Check device({self._connected_device.device_info['serial']}) can"
+          ' load required apk.'
+      ) from exc
+    except android_device.SnippetError as e:
+      # Error raises when registering a package twice or a package with
+      # duplicated name. Thus, It is okay to pass here as long as the snippet
+      # has been loaded.
+      self._logger.debug(str(e))
+
+  def _is_apk_installed(
+      self, device: android_device.AndroidDevice, package_name: str
+  ) -> bool:
+    """Checks if given package is already installed on the Android device.
+
+    Args:
+      device: An AndroidDevice object.
+      package_name: The Android app package name.
+
+    Raises:
+      adb.AdbError: When executing adb command fails.
+
+    Returns:
+      True if package is installed. False otherwise.
+    """
+    out = device.adb.shell(['pm', 'list', 'package'])
+    return bool(utils.grep('^package:%s$' % package_name, out))
+
+  def _install_apk(
+      self, device: android_device.AndroidDevice, apk_path: str
+  ) -> None:
+    """Installs required apk snippet to the given Android device.
+
+    Args:
+      device: An AndroidDevice object.
+      apk_path: The absolute file path where the apk is located.
+    """
+    device.adb.install(['-r', '-g', apk_path])
+
+  def _get_mbs_apk_path(self) -> str:
+    return os.path.join(
+        os.path.dirname(os.path.abspath(__file__)),
+        'android',
+        'app',
+        'snippet-0.0.0.apk',
+    )
+
+
+if __name__ == '__main__':
+  UIAutomator().load_device()
diff --git a/ui_automator_test.py b/ui_automator_test.py
new file mode 100644
index 0000000..951c80a
--- /dev/null
+++ b/ui_automator_test.py
@@ -0,0 +1,165 @@
+"""Unittest Lab exercise to test implementation of "Synonym Dictionary"."""
+import os
+import unittest
+
+from unittest import mock
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import adb
+from mobly.snippet import errors as snippet_errors
+
+from ui_automator import errors
+from ui_automator import ui_automator
+
+
+class UIAutomatorTest(unittest.TestCase):
+
+  def setUp(self):
+    """This method will be run before each of the test methods in the class."""
+    super().setUp()
+    self.ui_automator = ui_automator.UIAutomator()
+    self.mock_android_device = mock.patch.object(
+        android_device, 'AndroidDevice'
+    ).start()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_load_device_raises_an_error_when_adb_not_installed(
+      self, mock_get_all_instances
+  ):
+    mock_get_all_instances.side_effect = adb.AdbError(
+        cmd='adb devices',
+        stdout='fake_msg',
+        stderr='adb command not found',
+        ret_code=1,
+    )
+    with self.assertRaisesRegex(
+        errors.AdbError,
+        r'Please install adb and add it to your PATH environment variable\.',
+    ):
+      self.ui_automator.load_device()
+
+  @mock.patch.object(
+      android_device, 'get_all_instances', autospec=True, return_value=[]
+  )
+  def test_load_device_no_android_device_error(
+      self, unused_mock_get_all_instances
+  ):
+    with self.assertRaises(errors.NoAndroidDeviceError):
+      self.ui_automator.load_device()
+
+  @mock.patch.object(
+      android_device, 'get_all_instances', autospec=True, return_value=[1, 2]
+  )
+  def test_load_device_success(self, unused_mock_get_all_instances):
+    with self.assertLogs() as cm:
+      self.ui_automator.load_device()
+    self.assertEqual(cm.output, ['INFO:root:connected device: [1]'])
+
+  def test_load_snippet_without_load_device_raises_error(self):
+    with self.assertRaises(errors.NoAndroidDeviceError):
+      self.ui_automator.load_snippet()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_load_snippet_success(self, mock_get_all_instances):
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    self.ui_automator.load_device()
+    self.ui_automator.load_snippet()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_load_snippet_fails_with_server_start_pre_check_error(
+      self, mock_get_all_instances
+  ):
+    self.mock_android_device.load_snippet.side_effect = (
+        snippet_errors.ServerStartPreCheckError('fake_ad', 'fake_msg')
+    )
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    self.ui_automator.load_device()
+
+    with self.assertRaises(errors.AndroidDeviceNotReadyError):
+      self.ui_automator.load_snippet()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_load_snippet_fails_with_server_start_error(
+      self, mock_get_all_instances
+  ):
+    self.mock_android_device.load_snippet.side_effect = (
+        snippet_errors.ServerStartError('fake_ad', 'fake_msg')
+    )
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    self.ui_automator.load_device()
+
+    with self.assertRaises(errors.AndroidDeviceNotReadyError):
+      self.ui_automator.load_snippet()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_load_snippet_fails_with_protocol_error(self, mock_get_all_instances):
+    self.mock_android_device.load_snippet.side_effect = (
+        snippet_errors.ProtocolError('fake_ad', 'fake_msg')
+    )
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    self.ui_automator.load_device()
+
+    with self.assertRaises(errors.AndroidDeviceNotReadyError):
+      self.ui_automator.load_snippet()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_load_snippet_fails_with_snippet_error(self, mock_get_all_instances):
+    self.mock_android_device.load_snippet.side_effect = (
+        android_device.SnippetError('fake_device', 'fake_msg')
+    )
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    self.ui_automator.load_device()
+
+    with self.assertLogs(level='DEBUG') as cm:
+      self.ui_automator.load_snippet()
+    self.assertEqual(
+        cm.output,
+        [
+            "DEBUG:root:'fake_device'::Service<SnippetManagementService>"
+            ' fake_msg'
+        ],
+    )
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  @mock.patch.object(
+      os.path, 'dirname', autospec=True, return_value='/path/to/'
+  )
+  def test_load_snippet_installs_apk_when_apk_is_not_installed(
+      self, mock_dirname, mock_get_all_instances
+  ):
+    self.mock_android_device.adb.shell.return_value = (
+        b'package:installed.apk\n'
+    )
+    mock_get_all_instances.return_value = [self.mock_android_device]
+    self.ui_automator.load_device()
+
+    self.ui_automator.load_snippet()
+
+    self.mock_android_device.adb.install.assert_called_once_with(
+        ['-r', '-g', '/path/to/android/app/snippet-0.0.0.apk']
+    )
+    mock_dirname.assert_called_once()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  @mock.patch.object(os.path, 'dirname', autospec=True)
+  def test_load_snippet_should_not_install_apk_when_apk_is_installed(
+      self, mock_dirname, mock_get_all_instances
+  ):
+    self.mock_android_device.adb.shell.return_value = (
+        b'package:com.chip.interop.moblysnippet'
+    )
+    mock_get_all_instances.return_value = [self.mock_android_device]
+    self.ui_automator.load_device()
+
+    self.ui_automator.load_snippet()
+
+    self.mock_android_device.adb.install.assert_not_called()
+    mock_dirname.assert_not_called()
+
+
+if __name__ == '__main__':
+  unittest.main()