Repository: google/mobly

Issue:
logpersist.stop and logpersist.start scripts are not available on all devices
In addition to the root check, the actual logpersist.stop/start/cat script is not available on some devices, which can cause mobly to crash unexpectedly.


Discussion:
Can you specify the device models?
Is it possible to reach out to the manufacturer and have them fix this on their device?
Maybe?
But that seems impractical, since that means **manual** action is required to get each and every set of devices to work.
Specifically, I don't think it makes sense for the manufacturer to actually include this script in their build process since it's only really used for debugging purposes and is not user-facing.

Resolving patch:
diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -965,6 +965,31 @@ def cat_adb_log(self, tag, begin_time):
                         if in_range:
                             break
 
+    def _enable_logpersist(self):
+        """Attempts to enable logpersist daemon to persist logs."""
+        # Logpersist is only allowed on rootable devices because of excessive
+        # reads/writes for persisting logs.
+        if not self.is_rootable:
+            return
+
+        logpersist_warning = ('%s encountered an error enabling persistent'
+                              ' logs, logs may not get saved.')
+        # Android L and older versions do not have logpersist installed,
+        # so check that the logpersist scripts exists before trying to use
+        # them.
+        if not self.adb.has_shell_command('logpersist.start'):
+            logging.warning(logpersist_warning, self)
+            return
+
+        try:
+            # Disable adb log spam filter for rootable devices. Have to stop and
+            # clear settings first because 'start' doesn't support --clear option
+            # before Android N.
+            self.adb.shell('logpersist.stop --clear')
+            self.adb.shell('logpersist.start')
+        except adb.AdbError:
+            logging.warning(logpersist_warning, self)
+
     def start_adb_logcat(self, clear_log=True):
         """Starts a standing adb logcat collection in separate subprocesses and
         save the logcat in a file.
@@ -980,12 +1005,9 @@ def start_adb_logcat(self, clear_log=True):
                 'Logcat thread is already running, cannot start another one.')
         if clear_log:
             self._clear_adb_log()
-        # Disable adb log spam filter for rootable devices. Have to stop and
-        # clear settings first because 'start' doesn't support --clear option
-        # before Android N.
-        if self.is_rootable:
-            self.adb.shell('logpersist.stop --clear')
-            self.adb.shell('logpersist.start')
+
+        self._enable_logpersist()
+
         f_name = 'adblog,%s,%s.txt' % (self.model, self.serial)
         utils.create_dir(self.log_path)
         logcat_file_path = os.path.join(self.log_path, f_name)
diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py
--- a/mobly/controllers/android_device_lib/adb.py
+++ b/mobly/controllers/android_device_lib/adb.py
@@ -211,6 +211,24 @@ def getprop(self, prop_name):
         """
         return self.shell('getprop %s' % prop_name).decode('utf-8').strip()
 
+    def has_shell_command(self, command):
+        """Checks to see if a given check command exists on the device.
+
+        Args:
+            command: A string that is the name of the command to check.
+
+        Returns:
+            A boolean that is True if the command exists and False otherwise.
+        """
+        try:
+            output = self.shell(['command', '-v',
+                                 command]).decode('utf-8').strip()
+            return command in output
+        except AdbError:
+            # If the command doesn't exist, then 'command -v' can return
+            # an exit code > 1.
+            return False
+
     def forward(self, args=None, shell=False):
         with ADB_PORT_LOCK:
             return self._exec_adb_cmd('forward', args, shell, timeout=None)


Test patch:
diff --git a/tests/mobly/controllers/android_device_lib/adb_test.py b/tests/mobly/controllers/android_device_lib/adb_test.py
--- a/tests/mobly/controllers/android_device_lib/adb_test.py
+++ b/tests/mobly/controllers/android_device_lib/adb_test.py
@@ -39,6 +39,10 @@
                                         '.instrumentation.tests/com.android'
                                         '.common.support.test.runner'
                                         '.AndroidJUnitRunner')
+# Mock Shell Command
+MOCK_SHELL_COMMAND = 'ls'
+MOCK_COMMAND_OUTPUT = '/system/bin/ls'.encode('utf-8')
+MOCK_ADB_SHELL_COMMAND_CHECK = 'adb shell command -v ls'
 
 
 class AdbTest(unittest.TestCase):
@@ -176,6 +180,34 @@ def test_cli_cmd_to_string(self):
         cmd = 'adb -s meme do something ab_cd'
         self.assertEqual(adb.cli_cmd_to_string(cmd), cmd)
 
+    def test_has_shell_command_called_correctly(self):
+        with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            adb.AdbProxy().has_shell_command(MOCK_SHELL_COMMAND)
+            mock_exec_cmd.assert_called_once_with(
+                ['adb', 'shell', 'command', '-v', MOCK_SHELL_COMMAND],
+                shell=False,
+                timeout=None)
+
+    def test_has_shell_command_with_existing_command(self):
+        with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.return_value = MOCK_COMMAND_OUTPUT
+            self.assertTrue(
+                adb.AdbProxy().has_shell_command(MOCK_SHELL_COMMAND))
+
+    def test_has_shell_command_with_missing_command_on_older_devices(self):
+        with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.side_effect = adb.AdbError(
+                MOCK_ADB_SHELL_COMMAND_CHECK, '', '', 0)
+            self.assertFalse(
+                adb.AdbProxy().has_shell_command(MOCK_SHELL_COMMAND))
+
+    def test_has_shell_command_with_missing_command_on_newer_devices(self):
+        with mock.patch.object(adb.AdbProxy, '_exec_cmd') as mock_exec_cmd:
+            mock_exec_cmd.side_effect = adb.AdbError(
+                MOCK_ADB_SHELL_COMMAND_CHECK, '', '', 1)
+            self.assertFalse(
+                adb.AdbProxy().has_shell_command(MOCK_SHELL_COMMAND))
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py
--- a/tests/mobly/controllers/android_device_test.py
+++ b/tests/mobly/controllers/android_device_test.py
@@ -26,6 +26,7 @@
 
 from mobly import utils
 from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import adb
 
 from tests.lib import mock_android_device
 
@@ -50,6 +51,14 @@
 MockSnippetClient = mock.MagicMock()
 MockSnippetClient.package = MOCK_SNIPPET_PACKAGE_NAME
 
+# Mock AdbError for missing logpersist scripts
+MOCK_LOGPERSIST_STOP_MISSING_ADB_ERROR = adb.AdbError(
+    'logpersist.stop --clear', '',
+    '/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)
+
 
 class AndroidDeviceTest(unittest.TestCase):
     """This test class has unit tests for the implementation of everything
@@ -299,7 +308,7 @@ def test_AndroidDevice_serial_is_valid(self, MockFastboot, MockAdbProxy):
         """Verifies that the serial is a primitive string type and serializable.
         """
         ad = android_device.AndroidDevice(serial=1)
-        # In py2, checks that ad.serial is not the backported py3 str type, 
+        # In py2, checks that ad.serial is not the backported py3 str type,
         # which is not dumpable by yaml in py2.
         # In py3, new_str is equivalent to str, so this check is not
         # appropirate in py3.
@@ -627,6 +636,99 @@ def test_AndroidDevice_cat_adb_log(self, mock_timestamp_getter,
         # Stops adb logcat.
         ad.stop_adb_logcat()
 
+    @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_AndroidDevice__enable_logpersist_with_logpersist(
+            self, MockFastboot, MockAdbProxy):
+        mock_serial = '1'
+        mock_adb_proxy = MockAdbProxy.return_value
+        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.has_shell_command.side_effect = lambda command: {
+            'logpersist.start': True,
+            'logpersist.stop': True, }[command]
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        ad._enable_logpersist()
+        mock_adb_proxy.shell.assert_has_calls([
+            mock.call('logpersist.stop --clear'),
+            mock.call('logpersist.start'),
+        ])
+
+    @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_AndroidDevice__enable_logpersist_with_missing_all_logpersist(
+            self, MockFastboot, MockAdbProxy):
+        def adb_shell_helper(command):
+            if command == 'logpersist.start':
+                raise MOCK_LOGPERSIST_START_MISSING_ADB_ERROR
+            elif command == 'logpersist.stop --clear':
+                raise MOCK_LOGPERSIST_STOP_MISSING_ADB_ERROR
+            else:
+                return ''
+
+        mock_serial = '1'
+        mock_adb_proxy = MockAdbProxy.return_value
+        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.has_shell_command.side_effect = lambda command: {
+            'logpersist.start': False,
+            'logpersist.stop': False, }[command]
+        mock_adb_proxy.shell.side_effect = adb_shell_helper
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        ad._enable_logpersist()
+
+    @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_AndroidDevice__enable_logpersist_with_missing_logpersist_stop(
+            self, MockFastboot, MockAdbProxy):
+        def adb_shell_helper(command):
+            if command == 'logpersist.stop --clear':
+                raise MOCK_LOGPERSIST_STOP_MISSING_ADB_ERROR
+            else:
+                return ''
+
+        mock_serial = '1'
+        mock_adb_proxy = MockAdbProxy.return_value
+        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.has_shell_command.side_effect = lambda command: {
+            'logpersist.start': True,
+            'logpersist.stop': False, }[command]
+        mock_adb_proxy.shell.side_effect = adb_shell_helper
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        ad._enable_logpersist()
+
+    @mock.patch(
+        'mobly.controllers.android_device_lib.adb.AdbProxy',
+        return_value=mock.MagicMock())
+    @mock.patch('mobly.utils.stop_standing_subprocess')
+    def test_AndroidDevice__enable_logpersist_with_missing_logpersist_start(
+            self, MockFastboot, MockAdbProxy):
+        def adb_shell_helper(command):
+            if command == 'logpersist.start':
+                raise MOCK_LOGPERSIST_START_MISSING_ADB_ERROR
+            else:
+                return ''
+
+        mock_serial = '1'
+        mock_adb_proxy = MockAdbProxy.return_value
+        mock_adb_proxy.getprop.return_value = 'userdebug'
+        mock_adb_proxy.has_shell_command.side_effect = lambda command: {
+            'logpersist.start': False,
+            'logpersist.stop': True, }[command]
+        mock_adb_proxy.shell.side_effect = adb_shell_helper
+        ad = android_device.AndroidDevice(serial=mock_serial)
+        ad._enable_logpersist()
+
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',
         return_value=mock_android_device.MockAdbProxy('1'))