Repository: beeware/briefcase
Pull request #1676 (2024-02-29 23:18:50)

ADB log tailing color output
## Changes
- Inspired by https://github.com/beeware/briefcase/pull/1572
- Configures ADB to use color-coordinated output

![image](https://github.com/beeware/briefcase/assets/387848/87425ba0-5f11-4083-95a1-e7da5d40e9a0)

## Notes
- I finally took the time to figure out the regex to account for ANSI sequences in the output.
- The regex only handles ANSI sequences at the beginning and end of the output....but that's the only place I've seen it.

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct


rmartin16: In all its glory in CI: https://github.com/beeware/briefcase/actions/runs/8104029040/job/22150011201?pr=1676#step:28:624

One thing I was considering is that there isn't any way to disable this...`adb` doesn't seem to respect `NO_COLOR`, at least. It might be possible to inspect if RIch disabled color...and do the same for `adb logcat`...

Patch:
diff --git a/changes/1676.feature.rst b/changes/1676.feature.rst
new file mode 100644
index 00000000..e704474b
--- /dev/null
+++ b/changes/1676.feature.rst
@@ -0,0 +1,1 @@
+When an app runs on an Android device or emulator, the logging output is now colored.
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index 37e945c1..c05c3a0c 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -33,7 +33,8 @@ from briefcase import __version__
 SENSITIVE_SETTING_RE = re.compile(r"API|TOKEN|KEY|SECRET|PASS|SIGNATURE", flags=re.I)
 
 # 7-bit C1 ANSI escape sequences
-ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
+ANSI_ESC_SEQ_RE_DEF = r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"
+ANSI_ESCAPE_RE = re.compile(ANSI_ESC_SEQ_RE_DEF)
 
 
 class InputDisabled(Exception):
@@ -519,6 +520,20 @@ class Console:
         # `sys.__stdout__` is used because Rich captures and redirects `sys.stdout`
         return sys.__stdout__.isatty()
 
+    @property
+    def is_color_enabled(self):
+        """Is the underlying Rich console using color?
+
+        Rich can be explicitly configured to not use color at Console initialization or
+        the NO_COLOR environment variable; alternatively, the derived color system for
+        the terminal is influenced by attributes of the platform as well as FORCE_COLOR.
+        """
+        # no_color has precedence since color_system can be set even if color is disabled
+        if self.print.console.no_color:
+            return False
+        else:
+            return self.print.console.color_system is not None
+
     def progress_bar(self):
         """Returns a progress bar as a context manager."""
         return Progress(
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 19f23435..e2f0906d 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -1610,7 +1610,8 @@ Activity class not found while starting app.
                 pid,
             ]
             # Filter out some noisy and useless tags.
-            + [f"{tag}:S" for tag in ["EGL_emulation"]],
+            + [f"{tag}:S" for tag in ["EGL_emulation"]]
+            + (["--format=color"] if self.tools.input.is_color_enabled else []),
             env=self.tools.android_sdk.env,
             encoding="UTF-8",
             stdout=subprocess.PIPE,
@@ -1643,7 +1644,8 @@ Activity class not found while starting app.
                     "stdio:*",
                     "python.stdout:*",
                     "AndroidRuntime:*",
-                ],
+                ]
+                + (["--format=color"] if self.tools.input.is_color_enabled else []),
                 env=self.tools.android_sdk.env,
                 check=True,
                 encoding="UTF-8",
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index ffd04121..ef87d289 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -16,6 +16,7 @@ from briefcase.commands import (
     UpdateCommand,
 )
 from briefcase.config import AppConfig, parsed_version
+from briefcase.console import ANSI_ESC_SEQ_RE_DEF
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.android_sdk import AndroidSDK
 from briefcase.integrations.subprocess import SubprocessArgT
@@ -35,7 +36,11 @@ def safe_formal_name(name):
     return re.sub(r"\s+", " ", re.sub(r'[!/\\:<>"\?\*\|]', "", name)).strip()
 
 
-ANDROID_LOG_PREFIX_REGEX = re.compile(r"[A-Z]/(?P<tag>.*?): (?P<content>.*)")
+# Matches zero or more ANSI control chars wrapping the message for when
+# the Android emulator is printing in color.
+ANDROID_LOG_PREFIX_REGEX = re.compile(
+    rf"(?:{ANSI_ESC_SEQ_RE_DEF})*[A-Z]/(?P<tag>.*?): (?P<content>.*?(?=\x1B|$))(?:{ANSI_ESC_SEQ_RE_DEF})*"
+)
 
 
 def android_log_clean_filter(line):


Test patch:
diff --git a/tests/console/Console/test_is_color_enabled.py b/tests/console/Console/test_is_color_enabled.py
new file mode 100644
index 00000000..ca5f3cf1
--- /dev/null
+++ b/tests/console/Console/test_is_color_enabled.py
@@ -0,0 +1,33 @@
+from unittest.mock import PropertyMock
+
+import pytest
+from rich.console import ColorSystem
+
+from briefcase.console import Console, Printer
+
+
+@pytest.mark.parametrize(
+    "no_color, color_system, is_enabled",
+    [
+        (False, ColorSystem.TRUECOLOR, True),
+        (False, None, False),
+        (True, ColorSystem.TRUECOLOR, False),
+        (True, None, False),
+    ],
+)
+def test_is_color_enabled(no_color, color_system, is_enabled, monkeypatch):
+    """Color is enabled/disabled based on no_color and color_system."""
+    printer = Printer()
+    monkeypatch.setattr(
+        type(printer.console),
+        "color_system",
+        PropertyMock(return_value=color_system),
+    )
+    printer.console.no_color = no_color
+    console = Console(printer=printer)
+
+    # confirm these values to make sure they didn't change somehow...
+    assert printer.console.no_color is no_color
+    assert printer.console.color_system is color_system
+
+    assert console.is_color_enabled is is_enabled
diff --git a/tests/integrations/android_sdk/ADB/test_logcat.py b/tests/integrations/android_sdk/ADB/test_logcat.py
index 3013122b..7870dbaf 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat.py
@@ -1,9 +1,19 @@
 import subprocess
 from unittest import mock
 
+import pytest
 
-def test_logcat(mock_tools, adb):
+
+@pytest.mark.parametrize("is_color_enabled", [True, False])
+def test_logcat(mock_tools, adb, is_color_enabled, monkeypatch):
     """Invoking `logcat()` calls `Popen()` with the appropriate parameters."""
+    # Mock whether color is enabled for the console
+    monkeypatch.setattr(
+        type(mock_tools.input),
+        "is_color_enabled",
+        mock.PropertyMock(return_value=is_color_enabled),
+    )
+
     # Mock the result of calling Popen so we can compare against this return value
     popen = mock.MagicMock()
     mock_tools.subprocess.Popen.return_value = popen
@@ -22,7 +32,8 @@ def test_logcat(mock_tools, adb):
             "--pid",
             "1234",
             "EGL_emulation:S",
-        ],
+        ]
+        + (["--format=color"] if is_color_enabled else []),
         env=mock_tools.android_sdk.env,
         encoding="UTF-8",
         stdout=subprocess.PIPE,
diff --git a/tests/integrations/android_sdk/ADB/test_logcat_tail.py b/tests/integrations/android_sdk/ADB/test_logcat_tail.py
index c22cf3c7..51ad2e12 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat_tail.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat_tail.py
@@ -1,14 +1,22 @@
 import subprocess
 from datetime import datetime
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, PropertyMock
 
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
 
 
-def test_logcat_tail(mock_tools, adb):
+@pytest.mark.parametrize("is_color_enabled", [True, False])
+def test_logcat_tail(mock_tools, adb, is_color_enabled, monkeypatch):
     """Invoking `logcat_tail()` calls `run()` with the appropriate parameters."""
+    # Mock whether color is enabled for the console
+    monkeypatch.setattr(
+        type(mock_tools.input),
+        "is_color_enabled",
+        PropertyMock(return_value=is_color_enabled),
+    )
+
     # Invoke logcat_tail with a specific timestamp
     adb.logcat_tail(since=datetime(2022, 11, 10, 9, 8, 7))
 
@@ -27,7 +35,8 @@ def test_logcat_tail(mock_tools, adb):
             "stdio:*",
             "python.stdout:*",
             "AndroidRuntime:*",
-        ],
+        ]
+        + (["--format=color"] if is_color_enabled else []),
         env=mock_tools.android_sdk.env,
         check=True,
         encoding="UTF-8",
diff --git a/tests/platforms/android/gradle/test_android_log_clean_filter.py b/tests/platforms/android/gradle/test_android_log_clean_filter.py
index 69981d1e..f5191826 100644
--- a/tests/platforms/android/gradle/test_android_log_clean_filter.py
+++ b/tests/platforms/android/gradle/test_android_log_clean_filter.py
@@ -19,6 +19,13 @@ from briefcase.platforms.android.gradle import android_log_clean_filter
                 False,
             ),
         ),
+        (
+            "\x1b[32mD/libEGL  : loaded /vendor/lib64/egl/libEGL_emulation.so\x1b[0m",
+            (
+                "loaded /vendor/lib64/egl/libEGL_emulation.so",
+                False,
+            ),
+        ),
         (
             "D/stdio   : Could not find platform independent libraries <prefix>",
             ("Could not find platform independent libraries <prefix>", False),
@@ -27,28 +34,60 @@ from briefcase.platforms.android.gradle import android_log_clean_filter
             "D/MainActivity: onStart() start",
             ("onStart() start", False),
         ),
+        (
+            "\x1b[32mD/MainActivity: onStart() start\x1b[0m",
+            ("onStart() start", False),
+        ),
         # Python App messages
         (
             "I/python.stdout: Python app launched & stored in Android Activity class",
             ("Python app launched & stored in Android Activity class", True),
         ),
+        (
+            "\x1b[32mI/python.stdout: Python app launched & stored in Android Activity class\x1b[0m",
+            ("Python app launched & stored in Android Activity class", True),
+        ),
         (
             "I/python.stdout: ",
             ("", True),
         ),
+        (
+            "\x1b[32mI/python.stdout: \x1b[0m",
+            ("", True),
+        ),
+        (
+            "\x1b[32m\x1b[98mI/python.stdout: \x1b[32m\x1b[0m",
+            ("", True),
+        ),
+        (
+            "\x1b[32m\x1b[98mI/python.stdout: this is colored output\x1b[32m\x1b[0m",
+            ("this is colored output", True),
+        ),
         (
             "I/python.stderr: test_case (tests.foobar.test_other.TestOtherMethods)",
             ("test_case (tests.foobar.test_other.TestOtherMethods)", True),
         ),
+        (
+            "\x1b[32mI/python.stderr: test_case (tests.foobar.test_other.TestOtherMethods)\x1b[0m",
+            ("test_case (tests.foobar.test_other.TestOtherMethods)", True),
+        ),
         (
             "I/python.stderr: ",
             ("", True),
         ),
+        (
+            "\x1b[32mI/python.stderr: \x1b[0m",
+            ("", True),
+        ),
         # Unknown content
         (
             "This doesn't match the regex",
             ("This doesn't match the regex", False),
         ),
+        (
+            "\x1b[33mThis doesn't match the regex\x1b[33m",
+            ("\x1b[33mThis doesn't match the regex\x1b[33m", False),
+        ),
     ],
 )
 def test_filter(original, filtered):