Extend build info (#599)

diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
index 386603a..75d8cf3 100644
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -716,9 +716,23 @@
                            'info.')
             return
         info = {}
-        build_info = self.adb.getprops(['ro.build.id', 'ro.build.type'])
+        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
 
     @property
@@ -740,7 +754,7 @@
 
     @property
     def is_rootable(self):
-        return self.adb.getprop('ro.debuggable') == '1'
+        return self.build_info['debuggable'] == '1'
 
     @property
     def model(self):
@@ -757,10 +771,10 @@
                 if len(tokens) > 1:
                     return tokens[1].lower()
             return None
-        model = self.adb.getprop('ro.build.product').lower()
+        model = self.build_info['build_product'].lower()
         if model == 'sprout':
             return model
-        return self.adb.getprop('ro.product.name').lower()
+        return self.build_info['product_name'].lower()
 
     def load_config(self, config):
         """Add attributes to the AndroidDevice object based on config.
diff --git a/mobly/controllers/android_device_lib/jsonrpc_client_base.py b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
index 1cd6840..abe0bbb 100644
--- a/mobly/controllers/android_device_lib/jsonrpc_client_base.py
+++ b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
@@ -327,8 +327,8 @@
 
     def disable_hidden_api_blacklist(self):
         """If necessary and possible, disables hidden api blacklist."""
-        version_codename = self._ad.adb.getprop('ro.build.version.codename')
-        sdk_version = int(self._ad.adb.getprop('ro.build.version.sdk'))
+        version_codename = self._ad.build_info['build_version_codename']
+        sdk_version = int(self._ad.build_info['build_version_sdk'])
         # we check version_codename in addition to sdk_version because P builds
         # in development report sdk_version 27, but still enforce the blacklist.
         if self._ad.is_rootable and (sdk_version >= 28
diff --git a/tests/lib/mock_android_device.py b/tests/lib/mock_android_device.py
index dd1ae15..57527b4 100755
--- a/tests/lib/mock_android_device.py
+++ b/tests/lib/mock_android_device.py
@@ -28,6 +28,7 @@
     'ro.build.version.codename': 'Z',
     'ro.build.version.sdk': '28',
     'ro.product.name': 'FakeModel',
+    'ro.debuggable': '1',
     'sys.boot_completed': "1",
 }
 
diff --git a/tests/mobly/controllers/android_device_lib/services/logcat_test.py b/tests/mobly/controllers/android_device_lib/services/logcat_test.py
index 10e6cf3..b7467fd 100755
--- a/tests/mobly/controllers/android_device_lib/services/logcat_test.py
+++ b/tests/mobly/controllers/android_device_lib/services/logcat_test.py
@@ -57,11 +57,11 @@
 
 # Mock AdbError for missing logpersist scripts
 MOCK_LOGPERSIST_STOP_MISSING_ADB_ERROR = adb.AdbError(
-    'logpersist.stop --clear', '',
+    'logpersist.stop --clear', b'',
     '/system/bin/sh: logpersist.stop: not found', 0)
 MOCK_LOGPERSIST_START_MISSING_ADB_ERROR = adb.AdbError(
-    'logpersist.start --clear', '',
-    '/system/bin/sh: logpersist.stop: not found', 0)
+    'logpersist.start --clear', b'',
+    b'/system/bin/sh: logpersist.stop: not found', 0)
 
 
 class LogcatTest(unittest.TestCase):
@@ -367,8 +367,11 @@
                                                 MockAdbProxy):
         mock_serial = '1'
         mock_adb_proxy = MockAdbProxy.return_value
-        # Set getprop to return '1' to indicate the device is rootable.
-        mock_adb_proxy.getprop.return_value = '1'
+        mock_adb_proxy.getprops.return_value = {
+            'ro.build.id': 'AB42',
+            'ro.build.type': 'userdebug',
+            'ro.debuggable': '1',
+        }
         mock_adb_proxy.has_shell_command.side_effect = lambda command: {
             'logpersist.start': True,
             'logpersist.stop': True, }[command]
@@ -386,6 +389,29 @@
     @mock.patch(
         'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
         return_value=mock_android_device.MockFastbootProxy('1'))
+    def test__enable_logpersist_with_user_build_device(self, MockFastboot,
+                                                       MockAdbProxy):
+        mock_serial = '1'
+        mock_adb_proxy = MockAdbProxy.return_value
+        mock_adb_proxy.getprops.return_value = {
+            'ro.build.id': 'AB42',
+            'ro.build.type': 'user',
+            'ro.debuggable': '0',
+        }
+        mock_adb_proxy.has_shell_command.side_effect = lambda command: {
+            'logpersist.start': True,
+            'logpersist.stop': True, }[command]
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        logcat_service = logcat.Logcat(ad)
+        logcat_service._enable_logpersist()
+        mock_adb_proxy.shell.assert_not_called()
+
+    @mock.patch(
+        'mobly.controllers.android_device_lib.adb.AdbProxy',
+        return_value=mock.MagicMock())
+    @mock.patch(
+        'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+        return_value=mock_android_device.MockFastbootProxy('1'))
     def test__enable_logpersist_with_missing_all_logpersist(
             self, MockFastboot, MockAdbProxy):
         def adb_shell_helper(command):
@@ -394,11 +420,15 @@
             elif command == 'logpersist.stop --clear':
                 raise MOCK_LOGPERSIST_STOP_MISSING_ADB_ERROR
             else:
-                return ''
+                return b''
 
         mock_serial = '1'
         mock_adb_proxy = MockAdbProxy.return_value
-        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.getprops.return_value = {
+            'ro.build.id': 'AB42',
+            'ro.build.type': 'userdebug',
+            'ro.debuggable': '1',
+        }
         mock_adb_proxy.has_shell_command.side_effect = lambda command: {
             'logpersist.start': False,
             'logpersist.stop': False, }[command]
@@ -406,6 +436,7 @@
         ad = android_device.AndroidDevice(serial=mock_serial)
         logcat_service = logcat.Logcat(ad)
         logcat_service._enable_logpersist()
+        mock_adb_proxy.shell.assert_not_called()
 
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',
@@ -419,11 +450,15 @@
             if command == 'logpersist.stop --clear':
                 raise MOCK_LOGPERSIST_STOP_MISSING_ADB_ERROR
             else:
-                return ''
+                return b''
 
         mock_serial = '1'
         mock_adb_proxy = MockAdbProxy.return_value
-        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.getprops.return_value = {
+            'ro.build.id': 'AB42',
+            'ro.build.type': 'userdebug',
+            'ro.debuggable': '1',
+        }
         mock_adb_proxy.has_shell_command.side_effect = lambda command: {
             'logpersist.start': True,
             'logpersist.stop': False, }[command]
@@ -431,6 +466,9 @@
         ad = android_device.AndroidDevice(serial=mock_serial)
         logcat_service = logcat.Logcat(ad)
         logcat_service._enable_logpersist()
+        mock_adb_proxy.shell.assert_has_calls([
+            mock.call('logpersist.stop --clear'),
+        ])
 
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',
@@ -442,11 +480,15 @@
             if command == 'logpersist.start':
                 raise MOCK_LOGPERSIST_START_MISSING_ADB_ERROR
             else:
-                return ''
+                return b''
 
         mock_serial = '1'
         mock_adb_proxy = MockAdbProxy.return_value
-        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.getprops.return_value = {
+            'ro.build.id': 'AB42',
+            'ro.build.type': 'userdebug',
+            'ro.debuggable': '1',
+        }
         mock_adb_proxy.has_shell_command.side_effect = lambda command: {
             'logpersist.start': False,
             'logpersist.stop': True, }[command]
@@ -454,6 +496,7 @@
         ad = android_device.AndroidDevice(serial=mock_serial)
         logcat_service = logcat.Logcat(ad)
         logcat_service._enable_logpersist()
+        mock_adb_proxy.shell.assert_not_called()
 
     @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy')
     @mock.patch(
diff --git a/tests/mobly/controllers/android_device_lib/sl4a_client_test.py b/tests/mobly/controllers/android_device_lib/sl4a_client_test.py
index e3db6a4..e786b03 100755
--- a/tests/mobly/controllers/android_device_lib/sl4a_client_test.py
+++ b/tests/mobly/controllers/android_device_lib/sl4a_client_test.py
@@ -66,6 +66,11 @@
             installed_packages=['com.googlecode.android_scripting'])
         ad = mock.Mock()
         ad.adb = adb_proxy
+        ad.build_info = {
+            'build_version_codename':
+            ad.adb.getprop('ro.build.version.codename'),
+            'build_version_sdk': ad.adb.getprop('ro.build.version.sdk'),
+        }
         return sl4a_client.Sl4aClient(ad=ad)
 
     def _setup_mock_instrumentation_cmd(self, mock_start_standing_subprocess,
diff --git a/tests/mobly/controllers/android_device_lib/snippet_client_test.py b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
index 8b12a9f..d60ee46 100755
--- a/tests/mobly/controllers/android_device_lib/snippet_client_test.py
+++ b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
@@ -452,6 +452,11 @@
                                     MOCK_PACKAGE_NAME)])
         ad = mock.Mock()
         ad.adb = adb_proxy
+        ad.build_info = {
+            'build_version_codename':
+            ad.adb.getprop('ro.build.version.codename'),
+            'build_version_sdk': ad.adb.getprop('ro.build.version.sdk'),
+        }
         return snippet_client.SnippetClient(package=MOCK_PACKAGE_NAME, ad=ad)
 
     def _setup_mock_instrumentation_cmd(self, mock_start_standing_subprocess,
diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py
index 03fcd3a..ae81ae1 100755
--- a/tests/mobly/controllers/android_device_test.py
+++ b/tests/mobly/controllers/android_device_test.py
@@ -272,6 +272,68 @@
         build_info = ad.build_info
         self.assertEqual(build_info['build_id'], 'AB42')
         self.assertEqual(build_info['build_type'], 'userdebug')
+        self.assertEqual(build_info['build_version_codename'], 'Z')
+        self.assertEqual(build_info['build_version_sdk'], '28')
+        self.assertEqual(build_info['build_product'], 'FakeModel')
+        self.assertEqual(build_info['product_name'], 'FakeModel')
+        self.assertEqual(build_info['debuggable'], '1')
+
+    @mock.patch(
+        'mobly.controllers.android_device_lib.adb.AdbProxy',
+        return_value=mock_android_device.MockAdbProxy(
+            '1',
+            mock_properties={
+                'ro.build.id': 'AB42',
+                'ro.build.type': 'userdebug',
+            }))
+    @mock.patch(
+        'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+        return_value=mock_android_device.MockFastbootProxy('1'))
+    def test_AndroidDevice_build_info_with_minimal_properties(
+            self, MockFastboot, MockAdbProxy):
+        ad = android_device.AndroidDevice(serial='1')
+        build_info = ad.build_info
+        self.assertEqual(build_info['build_id'], 'AB42')
+        self.assertEqual(build_info['build_type'], 'userdebug')
+        self.assertEqual(build_info['build_version_codename'], '')
+        self.assertEqual(build_info['build_version_sdk'], '')
+        self.assertEqual(build_info['build_product'], '')
+        self.assertEqual(build_info['product_name'], '')
+        self.assertEqual(build_info['debuggable'], '')
+
+    @mock.patch(
+        'mobly.controllers.android_device_lib.adb.AdbProxy',
+        return_value=mock_android_device.MockAdbProxy(
+            '1',
+            mock_properties={
+                'ro.build.id': 'AB42',
+                'ro.build.type': 'userdebug',
+                'ro.debuggable': '1',
+            }))
+    @mock.patch(
+        'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+        return_value=mock_android_device.MockFastbootProxy('1'))
+    def test_AndroidDevice_is_rootable_when_userdebug_device(
+            self, MockFastboot, MockAdbProxy):
+        ad = android_device.AndroidDevice(serial='1')
+        self.assertTrue(ad.is_rootable)
+
+    @mock.patch(
+        'mobly.controllers.android_device_lib.adb.AdbProxy',
+        return_value=mock_android_device.MockAdbProxy(
+            '1',
+            mock_properties={
+                'ro.build.id': 'AB42',
+                'ro.build.type': 'user',
+                'ro.debuggable': '0',
+            }))
+    @mock.patch(
+        'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+        return_value=mock_android_device.MockFastbootProxy('1'))
+    def test_AndroidDevice_is_rootable_when_user_device(
+            self, MockFastboot, MockAdbProxy):
+        ad = android_device.AndroidDevice(serial='1')
+        self.assertFalse(ad.is_rootable)
 
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',
@@ -904,9 +966,13 @@
         return_value=mock_android_device.MockFastbootProxy('1'))
     @mock.patch(
         'mobly.controllers.android_device.AndroidDevice.is_boot_completed',
-        side_effect=[False, False, adb.AdbTimeoutError(
-            ['adb', 'shell', 'getprop sys.boot_completed'],
-            timeout=5, serial=1), True])
+        side_effect=[
+            False, False,
+            adb.AdbTimeoutError(
+                ['adb', 'shell', 'getprop sys.boot_completed'],
+                timeout=5,
+                serial=1), True
+        ])
     @mock.patch('time.sleep', return_value=None)
     @mock.patch('time.time', side_effect=[0, 5, 10, 15, 20, 25, 30])
     def test_AndroidDevice_wait_for_completion_completed(
@@ -918,7 +984,10 @@
             ad.wait_for_boot_completion()
         except (adb.AdbError, adb.AdbTimeoutError):
             raised = True
-        self.assertFalse(raised, 'adb.AdbError or adb.AdbTimeoutError exception raised but not handled.')
+        self.assertFalse(
+            raised,
+            'adb.AdbError or adb.AdbTimeoutError exception raised but not handled.'
+        )
 
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',
@@ -928,9 +997,13 @@
         return_value=mock_android_device.MockFastbootProxy('1'))
     @mock.patch(
         'mobly.controllers.android_device.AndroidDevice.is_boot_completed',
-        side_effect=[False, False, adb.AdbTimeoutError(
-            ['adb', 'shell', 'getprop sys.boot_completed'],
-            timeout=5, serial=1), False, False, False, False])
+        side_effect=[
+            False, False,
+            adb.AdbTimeoutError(
+                ['adb', 'shell', 'getprop sys.boot_completed'],
+                timeout=5,
+                serial=1), False, False, False, False
+        ])
     @mock.patch('time.sleep', return_value=None)
     @mock.patch('time.time', side_effect=[0, 5, 10, 15, 20, 25, 30])
     def test_AndroidDevice_wait_for_completion_never_boot(
@@ -943,7 +1016,10 @@
                 ad.wait_for_boot_completion(timeout=20)
         except (adb.AdbError, adb.AdbTimeoutError):
             raised = True
-        self.assertFalse(raised, 'adb.AdbError or adb.AdbTimeoutError exception raised but not handled.')
+        self.assertFalse(
+            raised,
+            'adb.AdbError or adb.AdbTimeoutError exception raised but not handled.'
+        )
 
 
 if __name__ == '__main__':