Allow overriding props with setters via config in `AndroidDevice`. (#644)

diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
index 0ac605a..f9122ef 100644
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -336,6 +336,7 @@
     Raises:
         Error: No devices are matched.
     """
+
     def _get_device_filter(ad):
         for k, v in kwargs.items():
             if not hasattr(ad, k):
@@ -437,6 +438,7 @@
         services: ServiceManager, the manager of long-running services on the
             device.
     """
+
     def __init__(self, serial=''):
         self._serial = str(serial)
         # logging.log_path only exists when this is used in an Mobly test run.
@@ -839,7 +841,7 @@
             Error: The config is trying to overwrite an existing attribute.
         """
         for k, v in config.items():
-            if hasattr(self, k):
+            if hasattr(self, k) and k not in _ANDROID_DEVICE_SETTABLE_PROPS:
                 raise DeviceError(
                     self,
                     ('Attribute %s already exists with value %s, cannot set '
@@ -1089,6 +1091,11 @@
         return self.__getattribute__(name)
 
 
+# Properties in AndroidDevice that have setters.
+# This line has to live below the AndroidDevice code.
+_ANDROID_DEVICE_SETTABLE_PROPS = utils.get_settable_properties(AndroidDevice)
+
+
 class AndroidDeviceLoggerAdapter(logging.LoggerAdapter):
     """A wrapper class that adds a prefix to each log line.
 
@@ -1103,6 +1110,7 @@
     Then each log line added by my_log will have a prefix
     '[AndroidDevice|<tag>]'
     """
+
     def process(self, msg, kwargs):
         msg = _DEBUG_PREFIX_TEMPLATE % (self.extra['tag'], msg)
         return (msg, kwargs)
diff --git a/mobly/utils.py b/mobly/utils.py
index 2a60bae..68ce39f 100644
--- a/mobly/utils.py
+++ b/mobly/utils.py
@@ -549,6 +549,21 @@
     return ' '.join([pipes.quote(arg) for arg in args])
 
 
+def get_settable_properties(cls):
+    """Gets the settable properties of a class.
+
+    Only returns the explicitly defined properties with setters.
+
+    Args:
+        cls: A class in Python.
+    """
+    results = []
+    for attr, value in vars(cls).items():
+        if isinstance(value, property) and value.fset is not None:
+            results.append(attr)
+    return results
+
+
 def find_subclasses_in_module(base_classes, module):
     """Finds the subclasses of the given classes in the given module.
 
diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py
index 490df50..6308833 100755
--- a/tests/mobly/controllers/android_device_test.py
+++ b/tests/mobly/controllers/android_device_test.py
@@ -45,6 +45,7 @@
     """This test class has unit tests for the implementation of everything
     under mobly.controllers.android_device.
     """
+
     def setUp(self):
         # Set log_path to logging since mobly logger setup is not called.
         if not hasattr(logging, 'log_path'):
@@ -276,6 +277,39 @@
         self.assertIsNotNone(ad.services.snippets)
 
     @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy',
+                return_value=mock_android_device.MockAdbProxy(1))
+    @mock.patch('mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+                return_value=mock_android_device.MockFastbootProxy(1))
+    @mock.patch('mobly.utils.create_dir')
+    def test_AndroidDevice_load_config(self, create_dir_mock, FastbootProxy,
+                                       MockAdbProxy):
+        mock_serial = '1'
+        config = {
+            'space': 'the final frontier',
+            'number': 1,
+            'debug_tag': 'my_tag'
+        }
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        ad.load_config(config)
+        self.assertEqual(ad.space, 'the final frontier')
+        self.assertEqual(ad.number, 1)
+        self.assertEqual(ad.debug_tag, 'my_tag')
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy',
+                return_value=mock_android_device.MockAdbProxy(1))
+    @mock.patch('mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+                return_value=mock_android_device.MockFastbootProxy(1))
+    @mock.patch('mobly.utils.create_dir')
+    def test_AndroidDevice_load_config_dup(self, create_dir_mock,
+                                           FastbootProxy, MockAdbProxy):
+        mock_serial = '1'
+        config = {'serial': 'new_serial'}
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        with self.assertRaisesRegex(android_device.DeviceError,
+                                    'Attribute serial already exists with'):
+            ad.load_config(config)
+
+    @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy',
                 return_value=mock_android_device.MockAdbProxy('1'))
     @mock.patch('mobly.controllers.android_device_lib.fastboot.FastbootProxy',
                 return_value=mock_android_device.MockFastbootProxy('1'))
diff --git a/tests/mobly/utils_test.py b/tests/mobly/utils_test.py
index 714c238..207273c 100755
--- a/tests/mobly/utils_test.py
+++ b/tests/mobly/utils_test.py
@@ -43,6 +43,7 @@
     """This test class has unit tests for the implementation of everything
     under mobly.utils.
     """
+
     def setUp(self):
         system = platform.system()
         self.tmp_dir = tempfile.mkdtemp()
@@ -247,6 +248,30 @@
         cmd = 'adb -s meme do something ab_cd'
         self.assertEqual(utils.cli_cmd_to_string(cmd), cmd)
 
+    def test_get_settable_properties(self):
+        class SomeClass(object):
+            regular_attr = 'regular_attr'
+            _foo = 'foo'
+            _bar = 'bar'
+
+            @property
+            def settable_prop(self):
+                return self._foo
+
+            @settable_prop.setter
+            def settable_prop(self, new_foo):
+                self._foo = new_foo
+
+            @property
+            def readonly_prop(self):
+                return self._bar
+
+            def func(self):
+                """Func should not be considered as a settable prop."""
+
+        actual = utils.get_settable_properties(SomeClass)
+        self.assertEqual(actual, ['settable_prop'])
+
     def test_find_subclasses_in_module_when_one_subclass(self):
         subclasses = utils.find_subclasses_in_module([base_test.BaseTestClass],
                                                      integration_test)