Cache build info (#600)

diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
index 75d8cf3..a9dfe98 100644
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -44,6 +44,18 @@
 ANDROID_DEVICE_EMPTY_CONFIG_MSG = 'Configuration is empty, abort!'
 ANDROID_DEVICE_NOT_LIST_CONFIG_MSG = 'Configuration should be a list, abort!'
 
+# System properties that are cached by the `AndroidDevice.build_info` property.
+# The only properties on this list should be read-only system properties.
+CACHED_SYSTEM_PROPS = [
+    'ro.build.id',
+    'ro.build.type',
+    'ro.build.version.codename',
+    'ro.build.version.sdk',
+    'ro.build.product',
+    'ro.debuggable',
+    'ro.product.name',
+]
+
 # Keys for attributes in configs that alternate the controller module behavior.
 # If this is False for a device, errors from that device will be ignored
 # during `create`. Default is True.
@@ -440,6 +452,8 @@
         self.log = AndroidDeviceLoggerAdapter(logging.getLogger(), {
             'tag': self.debug_tag
         })
+        self._build_info = None
+        self._is_rebooting = False
         self.adb = adb.AdbProxy(serial)
         self.fastboot = fastboot.FastbootProxy(serial)
         if not self.is_bootloader and self.is_rootable:
@@ -639,10 +653,30 @@
         For sample usage, see self.reboot().
         """
         self.services.stop_all()
+        # On rooted devices, system properties may change on reboot, so disable
+        # the `build_info` cache by setting `_is_rebooting` to True and
+        # repopulate it after reboot.
+        # Note, this logic assumes that instance variable assignment in Python
+        # is atomic; otherwise, `threading` data structures would be necessary.
+        # Additionally, nesting calls to `handle_reboot` while changing the
+        # read-only property values during reboot will result in stale values.
+        self._is_rebooting = True
         try:
             yield
         finally:
             self.wait_for_boot_completion()
+            # On boot completion, invalidate the `build_info` cache since any
+            # value it had from before boot completion is potentially invalid.
+            # If the value gets set after the final invalidation and before
+            # setting`_is_rebooting` to True, then that's okay because the
+            # device has finished rebooting at that point, and values at that
+            # point should be valid.
+            # If the reboot fails for some reason, then `_is_rebooting` is never
+            # set to False, which means the `build_info` cache remains disabled
+            # until the next reboot. This is relatively okay because the
+            # `build_info` cache is only minimizes adb commands.
+            self._build_info = None
+            self._is_rebooting = False
             if self.is_rootable:
                 self.root_adb()
         self.services.start_all()
@@ -715,25 +749,21 @@
             self.log.error('Device is in fastboot mode, could not get build '
                            'info.')
             return
-        info = {}
-        build_info = self.adb.getprops([
-            'ro.build.id',
-            'ro.build.type',
-            'ro.build.version.codename',
-            'ro.build.version.sdk',
-            'ro.build.product',
-            'ro.debuggable',
-            'ro.product.name',
-        ])
-        info['build_id'] = build_info['ro.build.id']
-        info['build_type'] = build_info['ro.build.type']
-        info['build_version_codename'] = build_info.get(
-            'ro.build.version.codename', '')
-        info['build_version_sdk'] = build_info.get('ro.build.version.sdk', '')
-        info['build_product'] = build_info.get('ro.build.product', '')
-        info['debuggable'] = build_info.get('ro.debuggable', '')
-        info['product_name'] = build_info.get('ro.product.name', '')
-        return info
+        if self._build_info is None or self._is_rebooting:
+            info = {}
+            build_info = self.adb.getprops(CACHED_SYSTEM_PROPS)
+            info['build_id'] = build_info['ro.build.id']
+            info['build_type'] = build_info['ro.build.type']
+            info['build_version_codename'] = build_info.get(
+                'ro.build.version.codename', '')
+            info['build_version_sdk'] = build_info.get('ro.build.version.sdk',
+                                                       '')
+            info['build_product'] = build_info.get('ro.build.product', '')
+            info['debuggable'] = build_info.get('ro.debuggable', '')
+            info['product_name'] = build_info.get('ro.product.name', '')
+            self._build_info = info
+            return info
+        return self._build_info
 
     @property
     def is_bootloader(self):
diff --git a/tests/lib/mock_android_device.py b/tests/lib/mock_android_device.py
index 57527b4..959623a 100755
--- a/tests/lib/mock_android_device.py
+++ b/tests/lib/mock_android_device.py
@@ -87,8 +87,9 @@
         self.serial = serial
         self.fail_br = fail_br
         self.fail_br_before_N = fail_br_before_N
+        self.getprops_call_count = 0
         if mock_properties is None:
-            self.mock_properties = DEFAULT_MOCK_PROPERTIES
+            self.mock_properties = DEFAULT_MOCK_PROPERTIES.copy()
         else:
             self.mock_properties = mock_properties
         if installed_packages is None:
@@ -129,6 +130,7 @@
             return self.mock_properties[params]
 
     def getprops(self, params):
+        self.getprops_call_count = self.getprops_call_count + 1
         return self.mock_properties
 
     def bugreport(self, args, shell=False, timeout=None):
diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py
index ae81ae1..51c1977 100755
--- a/tests/mobly/controllers/android_device_test.py
+++ b/tests/mobly/controllers/android_device_test.py
@@ -277,6 +277,8 @@
         self.assertEqual(build_info['build_product'], 'FakeModel')
         self.assertEqual(build_info['product_name'], 'FakeModel')
         self.assertEqual(build_info['debuggable'], '1')
+        self.assertEqual(
+            len(build_info), len(android_device.CACHED_SYSTEM_PROPS))
 
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',
@@ -303,6 +305,22 @@
 
     @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'))
+    def test_AndroidDevice_build_info_cached(self, MockFastboot, MockAdbProxy):
+        """Verifies the AndroidDevice object's basic attributes are correctly
+        set after instantiation.
+        """
+        ad = android_device.AndroidDevice(serial='1')
+        _ = ad.build_info
+        _ = ad.build_info
+        _ = ad.build_info
+        self.assertEqual(ad.adb.getprops_call_count, 1)
+
+    @mock.patch(
+        'mobly.controllers.android_device_lib.adb.AdbProxy',
         return_value=mock_android_device.MockAdbProxy(
             '1',
             mock_properties={
@@ -965,6 +983,51 @@
         'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
         return_value=mock_android_device.MockFastbootProxy('1'))
     @mock.patch(
+        'mobly.utils.start_standing_subprocess', return_value='process')
+    @mock.patch('mobly.utils.stop_standing_subprocess')
+    def test_AndroidDevice_handle_reboot_changes_build_info(
+            self, stop_proc_mock, start_proc_mock, FastbootProxy,
+            MockAdbProxy):
+        ad = android_device.AndroidDevice(serial='1')
+        with ad.handle_reboot():
+            ad.adb.mock_properties['ro.build.type'] = 'user'
+            ad.adb.mock_properties['ro.debuggable'] = '0'
+        self.assertEqual(ad.build_info['build_type'], 'user')
+        self.assertEqual(ad.build_info['debuggable'], '0')
+        self.assertFalse(ad.is_rootable)
+        self.assertEqual(ad.adb.getprops_call_count, 2)
+
+    @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.start_standing_subprocess', return_value='process')
+    @mock.patch('mobly.utils.stop_standing_subprocess')
+    def test_AndroidDevice_handle_reboot_changes_build_info_with_caching(
+            self, stop_proc_mock, start_proc_mock, FastbootProxy,
+            MockAdbProxy):
+        ad = android_device.AndroidDevice(serial='1')  # Call getprops 1.
+        rootable_states = [ad.is_rootable]
+        with ad.handle_reboot():
+            rootable_states.append(ad.is_rootable)  # Call getprops 2.
+            ad.adb.mock_properties['ro.debuggable'] = '0'
+            rootable_states.append(ad.is_rootable)  # Call getprops 3.
+        # Call getprops 4, on context manager end.
+        rootable_states.append(ad.is_rootable)  # Cached call.
+        rootable_states.append(ad.is_rootable)  # Cached call.
+        self.assertEqual(ad.adb.getprops_call_count, 4)
+        self.assertEqual(rootable_states, [True, True, False, False, False])
+
+    @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.controllers.android_device.AndroidDevice.is_boot_completed',
         side_effect=[
             False, False,