Add retries for adb getprops. (#597)


diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py
index 81e8a46..e6658fd 100644
--- a/mobly/controllers/android_device_lib/adb.py
+++ b/mobly/controllers/android_device_lib/adb.py
@@ -18,6 +18,7 @@
 import logging
 import psutil
 import subprocess
+import time
 import threading
 
 from mobly import utils
@@ -34,6 +35,8 @@
 
 # Adb getprop call should never take too long.
 DEFAULT_GETPROP_TIMEOUT_SEC = 5
+DEFAULT_GETPROPS_ATTEMPTS = 3
+DEFAULT_GETPROPS_RETRY_SLEEP_SEC = 1
 
 
 class Error(Exception):
@@ -332,13 +335,23 @@
             A dict containing name-value pairs of the properties requested, if
             they exist.
         """
-        raw_output = self.shell(
-            ['getprop'], timeout=DEFAULT_GETPROP_TIMEOUT_SEC)
-        properties = self._parse_getprop_output(raw_output)
+        attempts = DEFAULT_GETPROPS_ATTEMPTS
         results = {}
-        for name in prop_names:
-            if name in properties:
-                results[name] = properties[name]
+        for attempt in range(attempts):
+            # The ADB getprop command can randomly return empty string, so try
+            # multiple times. This value should always be non-empty if the device
+            # in a working state.
+            raw_output = self.shell(
+                ['getprop'], timeout=DEFAULT_GETPROP_TIMEOUT_SEC)
+            properties = self._parse_getprop_output(raw_output)
+            if properties:
+                for name in prop_names:
+                    if name in properties:
+                        results[name] = properties[name]
+                break
+            # Don't call sleep on the last attempt.
+            if attempt < attempts - 1:
+                time.sleep(DEFAULT_GETPROPS_RETRY_SLEEP_SEC)
         return results
 
     def has_shell_command(self, command):
diff --git a/tests/mobly/controllers/android_device_lib/adb_test.py b/tests/mobly/controllers/android_device_lib/adb_test.py
index 25ebde9..4a0dc34 100755
--- a/tests/mobly/controllers/android_device_lib/adb_test.py
+++ b/tests/mobly/controllers/android_device_lib/adb_test.py
@@ -174,8 +174,8 @@
         self._mock_execute_and_process_stdout_process(mock_popen)
         mock_handler = mock.MagicMock()
         mock_popen.return_value.communicate = mock.Mock(
-            return_value=(unexpected_stdout,
-                          MOCK_DEFAULT_STDERR.encode('utf-8')))
+            return_value=(unexpected_stdout, MOCK_DEFAULT_STDERR.encode(
+                'utf-8')))
 
         err = adb.AdbProxy()._execute_and_process_stdout(
             ['fake_cmd'], shell=False, handler=mock_handler)
@@ -508,7 +508,8 @@
         }
         self.assertEqual(parsed_props, expected_output)
 
-    def test_getprops(self):
+    @mock.patch('time.sleep', return_value=mock.MagicMock())
+    def test_getprops(self, mock_sleep):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
             mock_exec_cmd.return_value = (
                 b'\n[sendbug.preferred.domain]: [google.com]\n'
@@ -521,17 +522,52 @@
                 'sendbug.preferred.domain',  # string value
                 'nonExistentProp'
             ])
-            self.assertEqual(
-                actual_output, {
-                    'sys.wifitracing.started': '1',
-                    'sys.uidcpupower': '',
-                    'sendbug.preferred.domain': 'google.com'
-                })
+            self.assertEqual(actual_output, {
+                'sys.wifitracing.started': '1',
+                'sys.uidcpupower': '',
+                'sendbug.preferred.domain': 'google.com'
+            })
             mock_exec_cmd.assert_called_once_with(
                 ['adb', 'shell', 'getprop'],
                 shell=False,
                 stderr=None,
                 timeout=adb.DEFAULT_GETPROP_TIMEOUT_SEC)
+            mock_sleep.assert_not_called()
+
+    @mock.patch('time.sleep', return_value=mock.MagicMock())
+    def test_getprops_when_empty_string_randomly_returned(self, mock_sleep):
+        with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.side_effect = [
+                b'', (b'\n[ro.build.id]: [AB42]\n'
+                      b'[ro.build.type]: [userdebug]\n\n')
+            ]
+            actual_output = adb.AdbProxy().getprops(['ro.build.id'])
+            self.assertEqual(actual_output, {
+                'ro.build.id': 'AB42',
+            })
+            self.assertEqual(mock_exec_cmd.call_count, 2)
+            mock_exec_cmd.assert_called_with(
+                ['adb', 'shell', 'getprop'],
+                shell=False,
+                stderr=None,
+                timeout=adb.DEFAULT_GETPROP_TIMEOUT_SEC)
+            self.assertEqual(mock_sleep.call_count, 1)
+            mock_sleep.assert_called_with(1)
+
+    @mock.patch('time.sleep', return_value=mock.MagicMock())
+    def test_getprops_when_empty_string_always_returned(self, mock_sleep):
+        with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = b''
+            actual_output = adb.AdbProxy().getprops(['ro.build.id'])
+            self.assertEqual(actual_output, {})
+            self.assertEqual(mock_exec_cmd.call_count, 3)
+            mock_exec_cmd.assert_called_with(
+                ['adb', 'shell', 'getprop'],
+                shell=False,
+                stderr=None,
+                timeout=adb.DEFAULT_GETPROP_TIMEOUT_SEC)
+            self.assertEqual(mock_sleep.call_count, 2)
+            mock_sleep.assert_called_with(1)
 
     def test_forward(self):
         with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd: