Add a flag for decommissioning functionality

PiperOrigin-RevId: 580450009
diff --git a/ui_automator/ui_automator.py b/ui_automator/ui_automator.py
index a9edc5b..36bf327 100644
--- a/ui_automator/ui_automator.py
+++ b/ui_automator/ui_automator.py
@@ -21,7 +21,7 @@
 import logging
 import os
 import re
-from typing import Any, Callable, Sequence
+from typing import Any, Callable
 
 from absl import app
 from absl import flags
@@ -48,13 +48,18 @@
     'Use --commission {DeviceName},{PairingCode},{GHARoom} to commission a'
     ' device to google fabric on GHA.'
 )
-
 _COMMISSION = flags.DEFINE_list(
     name='commission',
     default=None,
     help=_COMMISSIONING_FLAG_USAGE_GUIDE,
 )
 
+_DECOMMISSION = flags.DEFINE_string(
+    name='decommission',
+    default='',
+    help='Use --decommission {DeviceName} to remove the device on GHA.',
+)
+
 
 def _validate_commssioning_arg(
     device_name: str, pairing_code: str, gha_room: str
@@ -266,6 +271,35 @@
           f' device({self._connected_device.device_info["serial"]}).'
       ) from e
 
+  @get_android_device_ready
+  def decommission_device(self, device_name: str):
+    """Removes given device through installed apk `mbs` on Google Home App.
+
+    Args:
+        device_name: Display name of the device needs to be removed on GHA.
+
+    Raises:
+        ValueError: When passed argument is invalid.
+        MoblySnippetError: When running `removeDevice` method in snippet apk
+        encountered an error.
+    """
+    if not re.fullmatch(r'\S{1,24}', device_name):
+      raise ValueError(
+          'Value of DeviceName is invalid. Device name should be no more than'
+          ' 24 characters.'
+      )
+
+    self._logger.info('Start removing the device.')
+    try:
+      self._connected_device.mbs.removeDevice(device_name)
+      self._logger.info('Successfully remove the device on GHA.')
+    # TODO(b/298903492): Narrow exception type.
+    except Exception as e:
+      raise errors.MoblySnippetError(
+          f'Unable to remove {device_name} from GHA'
+          f' on device({self._connected_device.device_info["serial"]}).'
+      ) from e
+
   def _get_mbs_apk_path(self) -> str:
     return os.path.join(
         os.path.dirname(os.path.abspath(__file__)),
@@ -275,16 +309,20 @@
     )
 
 
-def _commission(ui_automator: UIAutomator) -> None:
-  """Commissions a device to google fabric on GHA with flag values."""
+def _process_flags(ui_automator: UIAutomator) -> None:
+  """Does specific action based on given flag values."""
   if _COMMISSION.value:
     ui_automator.commission_device(*_COMMISSION.value)
+  if _DECOMMISSION.value:
+    ui_automator.decommission_device(_DECOMMISSION.value)
 
 
-def _main(argv: Sequence[str]) -> None:
-  del argv
+def _main(argv) -> None:
+  if argv and len(argv) > 1:
+    raise app.UsageError(f'Too many command-line arguments: {argv!r}')
+
   ui_automator = UIAutomator()
-  _commission(ui_automator)
+  _process_flags(ui_automator)
 
 
 def run():
diff --git a/ui_automator/ui_automator_test.py b/ui_automator/ui_automator_test.py
index 4de7691..10f5c15 100644
--- a/ui_automator/ui_automator_test.py
+++ b/ui_automator/ui_automator_test.py
@@ -19,6 +19,7 @@
 import unittest
 from unittest import mock
 
+from absl.testing import flagsaver
 from mobly.controllers import android_device
 from mobly.controllers.android_device_lib import adb
 from mobly.snippet import errors as snippet_errors
@@ -38,18 +39,18 @@
     'minThread': '231456000',
 }
 _PYTHON_PATH = subprocess.check_output(['which', 'python']).decode('utf-8')
-_PYTHON_BIN_PATH = _PYTHON_PATH[:-7]
-_FAKE_VALID_SYS_ARGV = [
+_PYTHON_BIN_PATH = _PYTHON_PATH.removesuffix('python')
+_FAKE_VALID_SYS_ARGV_FOR_COMMISSIONING = [
     _PYTHON_BIN_PATH + 'ui-automator',
     '--commission',
     'm5stack,34970112332,Office',
 ]
-_FAKE_SYS_ARGV_WITH_INVALID_PAIRING_CODE = [
+_FAKE_VALID_SYS_ARGV_FOR_DECOMMISSIONING = [
     _PYTHON_BIN_PATH + 'ui-automator',
-    '--commission',
-    'm5stack,3497,Office',
+    '--decommission',
+    'm5stack',
 ]
-_FAKE_SYS_ARGV_WITH_INVALID_LENGTH = [
+_FAKE_SYS_ARGV_FOR_COMMISSIONING_WITH_INVALID_LENGTH = [
     _PYTHON_BIN_PATH + 'ui-automator',
     '--commission',
     '',
@@ -65,6 +66,7 @@
     self.mock_android_device = mock.patch.object(
         android_device, 'AndroidDevice'
     ).start()
+    self.addCleanup(mock.patch.stopall)
 
   @mock.patch.object(android_device, 'get_all_instances', autospec=True)
   def test_load_device_raises_an_error_when_adb_not_installed(
@@ -254,8 +256,8 @@
     )
     mock_dirname.assert_called_once()
 
-  @mock.patch.object(ui_automator.UIAutomator, 'load_device')
-  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+  @mock.patch.object(ui_automator.UIAutomator, 'load_device', autospec=True)
+  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True)
   def test_get_android_device_ready_raises_an_error_when_load_device_throws_an_error(
       self, mock_load_snippet, mock_load_device
   ):
@@ -276,8 +278,8 @@
     mock_load_device.assert_called_once()
     mock_load_snippet.assert_not_called()
 
-  @mock.patch.object(ui_automator.UIAutomator, 'load_device')
-  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+  @mock.patch.object(ui_automator.UIAutomator, 'load_device', autospec=True)
+  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True)
   def test_get_android_device_ready_raises_an_error_when_load_snippet_throws_an_error(
       self, mock_load_snippet, mock_load_device
   ):
@@ -298,8 +300,8 @@
     mock_load_snippet.assert_called_once()
     mock_load_device.assert_called_once()
 
-  @mock.patch.object(ui_automator.UIAutomator, 'load_device')
-  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+  @mock.patch.object(ui_automator.UIAutomator, 'load_device', autospec=True)
+  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True)
   def test_get_android_device_ready_raises_no_error_on_success(
       self, mock_load_snippet, mock_load_device
   ):
@@ -313,8 +315,8 @@
     mock_load_snippet.assert_called_once()
     mock_load_device.assert_called_once()
 
-  @mock.patch.object(android_device, 'get_all_instances')
-  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True)
   def test_commission_device_raises_an_error_when_device_is_not_ready(
       self, mock_load_snippet, mock_get_all_instances
   ):
@@ -329,7 +331,7 @@
           _FAKE_MATTER_DEVICE_NAME, _FAKE_PAIRING_CODE, _FAKE_GHA_ROOM
       )
 
-  @mock.patch.object(android_device, 'get_all_instances')
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
   def test_commission_device_calls_a_method_in_snippet_with_desired_args(
       self, mock_get_all_instances
   ):
@@ -351,7 +353,7 @@
         _GOOGLE_HOME_APP, expected_matter_device
     )
 
-  @mock.patch.object(android_device, 'get_all_instances')
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
   def test_commission_device_fails_when_snippet_method_throws_an_error(
       self, mock_get_all_instances
   ):
@@ -360,21 +362,20 @@
         'Unable to continue automated commissioning process on'
         f' device({self.mock_android_device.device_info["serial"]}).'
     )
+    self.mock_android_device.mbs.commissionDevice.side_effect = Exception(
+        'Can not find next button in the page.'
+    )
 
-    with mock.patch.object(self.mock_android_device, 'mbs') as snippet:
-      snippet.commissionDevice.side_effect = Exception(
-          'Can not find next button in the page.'
+    with self.assertRaises(errors.MoblySnippetError) as exc:
+      self.ui_automator.commission_device(
+          _FAKE_MATTER_DEVICE_NAME,
+          _FAKE_PAIRING_CODE,
+          _FAKE_GHA_ROOM,
       )
-      with self.assertRaises(errors.MoblySnippetError) as exc:
-        self.ui_automator.commission_device(
-            _FAKE_MATTER_DEVICE_NAME,
-            _FAKE_PAIRING_CODE,
-            _FAKE_GHA_ROOM,
-        )
 
     self.assertIn(expected_error_message, str(exc.exception))
 
-  @mock.patch.object(android_device, 'get_all_instances')
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
   def test_commission_device_raises_an_error_when_device_name_exceeds_limit(
       self, mock_get_all_instances
   ):
@@ -392,7 +393,7 @@
           _FAKE_GHA_ROOM,
       )
 
-  @mock.patch.object(android_device, 'get_all_instances')
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
   def test_commission_device_raises_an_error_when_pairing_code_is_invalid(
       self, mock_get_all_instances
   ):
@@ -410,7 +411,7 @@
           gha_room=_FAKE_GHA_ROOM,
       )
 
-  @mock.patch.object(android_device, 'get_all_instances')
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
   def test_commission_device_raises_an_error_when_gha_room_is_invalid(
       self, mock_get_all_instances
   ):
@@ -433,25 +434,32 @@
           gha_room=invalid_gha_room,
       )
 
-  @mock.patch.object(sys, 'exit')
-  @mock.patch.object(ui_automator.UIAutomator, 'commission_device')
+  @flagsaver.flagsaver(
+      (ui_automator._COMMISSION, ['m5stack', '34970112332', 'Office'])
+  )
+  @mock.patch.object(sys, 'exit', autospec=True)
+  @mock.patch.object(
+      ui_automator.UIAutomator, 'commission_device', autospec=True
+  )
   def test_run_calls_commission_device_with_valid_arguments(
       self, mock_commission_device, mock_exit
   ):
-    with mock.patch.object(sys, 'argv', _FAKE_VALID_SYS_ARGV):
+    with mock.patch.object(sys, 'argv', _FAKE_VALID_SYS_ARGV_FOR_COMMISSIONING):
       ui_automator.run()
 
     mock_commission_device.assert_called_once_with(
-        'm5stack', '34970112332', 'Office'
+        mock.ANY, 'm5stack', '34970112332', 'Office'
     )
     mock_exit.assert_called()
 
-  @mock.patch.object(sys.stderr, 'write')
-  @mock.patch.object(sys, 'exit')
+  @mock.patch.object(sys.stderr, 'write', autospec=True)
+  @mock.patch.object(sys, 'exit', autospec=True)
   def test_commission_with_cmd_invalid_arg_should_stderr(
       self, mock_exit, mock_stderr_write
   ):
-    with mock.patch.object(sys, 'argv', _FAKE_SYS_ARGV_WITH_INVALID_LENGTH):
+    with mock.patch.object(
+        sys, 'argv', _FAKE_SYS_ARGV_FOR_COMMISSIONING_WITH_INVALID_LENGTH
+    ):
       ui_automator.run()
 
     self.assertEqual(mock_stderr_write.call_count, 2)
@@ -470,6 +478,72 @@
     self.mock_android_device.mbs.commissionDevice.assert_not_called()
     mock_exit.assert_called()
 
+  @flagsaver.flagsaver((ui_automator._DECOMMISSION, 'm5stack'))
+  @mock.patch.object(
+      ui_automator.UIAutomator, 'commission_device', autospec=True
+  )
+  @mock.patch.object(sys, 'exit', autospec=True)
+  @mock.patch.object(
+      ui_automator.UIAutomator, 'decommission_device', autospec=True
+  )
+  def test_run_calls_decommission_device_with_valid_arguments(
+      self, mock_decommission_device, mock_exit, mock_commission_device
+  ):
+    with mock.patch.object(
+        sys, 'argv', _FAKE_VALID_SYS_ARGV_FOR_DECOMMISSIONING
+    ):
+      ui_automator.run()
+
+    mock_commission_device.assert_not_called()
+    mock_decommission_device.assert_called_once_with(mock.ANY, 'm5stack')
+    mock_exit.assert_called()
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_decommission_device_raises_an_error_with_invalid_device_name(
+      self, mock_get_all_instances
+  ):
+    mock_get_all_instances.return_value = [self.mock_android_device]
+    expected_error_message = (
+        'Value of DeviceName is invalid. Device name should be no more than 24'
+        ' characters.'
+    )
+    with self.assertRaisesRegex(ValueError, expected_error_message):
+      self.ui_automator.decommission_device('device_name_longer_than_24_chars')
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_decommission_device_fails_when_snippet_method_throws_an_error(
+      self, mock_get_all_instances
+  ):
+    mock_get_all_instances.return_value = [self.mock_android_device]
+    expected_error_message = (
+        f'Unable to remove {_FAKE_MATTER_DEVICE_NAME} from GHA'
+        f' on device({self.mock_android_device.device_info["serial"]}).'
+    )
+    self.mock_android_device.mbs.removeDevice.side_effect = Exception(
+        'Can not remove the device.'
+    )
+
+    with self.assertRaises(errors.MoblySnippetError) as exc:
+      self.ui_automator.decommission_device(_FAKE_MATTER_DEVICE_NAME)
+
+    self.assertIn(expected_error_message, str(exc.exception))
+
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_decommission_device_successfully_removes_a_device(
+      self, mock_get_all_instances
+  ):
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    with self.assertLogs() as cm:
+      self.ui_automator.decommission_device(_FAKE_MATTER_DEVICE_NAME)
+
+    self.mock_android_device.mbs.removeDevice.assert_called_once_with(
+        _FAKE_MATTER_DEVICE_NAME
+    )
+    self.assertEqual(
+        cm.output[2], 'INFO:root:Successfully remove the device on GHA.'
+    )
+
 
 if __name__ == '__main__':
   unittest.main()