Repository: beeware/briefcase

Issue:
Android emulator check can return a false positive
See the log file below at 17:38:46:

[briefcase.2022_08_23-17_38_49.run.log](https://github.com/beeware/briefcase/files/9401696/briefcase.2022_08_23-17_38_49.run.log)

The emulator is not in the list of packages shown by sdkmanager. The user reported that the "emulator" directory contained nothing except the file `.installer/.installData`, with the following content:
```
#Mon Aug 22 06:11:28 CST 2022
class=com.android.repository.impl.installer.BasicInstaller
path=C:\Users\Administrator\AppData\Local\BeeWare\briefcase\Cache\tools\android_sdk\.temp\PackageOperation01
```

However, Briefcase incorrectly says the emulator is already installed, because the "emuilator" directory exists. This eventually led to Briefcase crashing while trying to run `emulator -list-avds`.

I don't know how this SDK configuration arose, but Briefcase could recover from it by checking for the emulator executable (`self.emulator_path`) rather than just the  containing directory. This is consistent with the way it already checks for the sdkmanager executable.

**Environment:**
 - Operating System: Windows 10
 - Python version: 3.8
 - Software versions:
   - Briefcase: 0.3.9


Comments:

Patch:
diff --git a/changes/837.bugfix.rst b/changes/837.bugfix.rst
new file mode 100644
index 00000000..5473bb5f
--- /dev/null
+++ b/changes/837.bugfix.rst
@@ -0,0 +1,1 @@
+The RCEdit plugin can now be upgraded.
diff --git a/changes/841.bugfix.rst b/changes/841.bugfix.rst
new file mode 100644
index 00000000..f05efe07
--- /dev/null
+++ b/changes/841.bugfix.rst
@@ -0,0 +1,1 @@
+When verifying the existence of the Android emulator, Briefcase now looks for the actual binary, not the folder that contains the binary. This was causing false positives on some Android SDK setups.
diff --git a/changes/842.feature.rst b/changes/842.feature.rst
new file mode 100644
index 00000000..21b07dad
--- /dev/null
+++ b/changes/842.feature.rst
@@ -0,0 +1,1 @@
+Log messages can now be captured on iOS if they originate from a dynamically loaded module.
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index ecc7193a..48dfe94d 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -406,7 +406,7 @@ connection.
         # might be missing.
         (self.root_path / "platforms").mkdir(exist_ok=True)
 
-        if (self.root_path / "emulator").exists():
+        if (self.emulator_path).exists():
             self.command.logger.debug("Android emulator is already installed.")
             return
 
diff --git a/src/briefcase/integrations/rcedit.py b/src/briefcase/integrations/rcedit.py
index 375f7320..16886a25 100644
--- a/src/briefcase/integrations/rcedit.py
+++ b/src/briefcase/integrations/rcedit.py
@@ -3,6 +3,7 @@ from briefcase.exceptions import MissingToolError
 
 class RCEdit:
     name = "rcedit"
+    full_name = "RCEdit"
 
     def __init__(self, command):
         self.command = command
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index cb248810..a1bdca11 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -351,6 +351,19 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
             ) from e
 
         # Start log stream for the app.
+        # The following sets up a log stream filter that looks for:
+        #  1. a log sender that matches that app binary; or,
+        #  2. a log sender of that is a Python extension module,
+        #     and a process that matches the app binary.
+        # Case (1) works when the standard libary is statically linked,
+        #   and for native NSLog() calls in the bootstrap binary
+        # Case (2) works when the standard library is dynamically linked,
+        #   and ctypes (which handles the NSLog integration) is an
+        #   extension module.
+        # It's not enough to filter on *just* the processImagePath,
+        # as the process will generate lots of system-level messages.
+        # We can't filter on *just* the senderImagePath, because other
+        # apps will generate log messages that would be caught by the filter.
         simulator_log_popen = self.subprocess.Popen(
             [
                 "xcrun",
@@ -362,7 +375,9 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
                 "--style",
                 "compact",
                 "--predicate",
-                f'senderImagePath ENDSWITH "/{app.formal_name}"',
+                f'senderImagePath ENDSWITH "/{app.formal_name}"'
+                f' OR (processImagePath ENDSWITH "/{app.formal_name}"'
+                ' AND senderImagePath ENDSWITH "-iphonesimulator.so")',
             ],
             stdout=subprocess.PIPE,
             stderr=subprocess.STDOUT,
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 6c9530d7..1f90a3c5 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -47,6 +47,10 @@ class macOSRunMixin:
         #  2. a log sender of libffi, and a process that matches the app binary.
         # Case (1) works for pre-Python 3.9 static linked binaries.
         # Case (2) works for Python 3.9+ dynamic linked binaries.
+        # It's not enough to filter on *just* the processImagePath,
+        # as the process will generate lots of system-level messages.
+        # We can't filter on *just* the senderImagePath, because other
+        # apps will generate log messages that would be caught by the filter.
         sender = os.fsdecode(
             self.binary_path(app) / "Contents" / "MacOS" / app.formal_name
         )


Test patch:
diff --git a/tests/integrations/android_sdk/ADB/test_command.py b/tests/integrations/android_sdk/ADB/test_command.py
index 07fdb370..cc5250ed 100644
--- a/tests/integrations/android_sdk/ADB/test_command.py
+++ b/tests/integrations/android_sdk/ADB/test_command.py
@@ -1,5 +1,6 @@
 import os
 import subprocess
+import sys
 from pathlib import Path
 
 import pytest
@@ -18,7 +19,12 @@ def test_simple_command(mock_sdk, tmp_path):
     # Check that adb was invoked with the expected commands
     mock_sdk.command.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(tmp_path / "sdk" / "platform-tools" / "adb"),
+            os.fsdecode(
+                tmp_path
+                / "sdk"
+                / "platform-tools"
+                / f"adb{'.exe' if sys.platform == 'win32' else ''}"
+            ),
             "-s",
             "exampleDevice",
             "example",
@@ -63,7 +69,12 @@ def test_error_handling(mock_sdk, tmp_path, name, exception):
     # Check that adb was invoked as expected
     mock_sdk.command.subprocess.check_output.assert_called_once_with(
         [
-            os.fsdecode(tmp_path / "sdk" / "platform-tools" / "adb"),
+            os.fsdecode(
+                tmp_path
+                / "sdk"
+                / "platform-tools"
+                / f"adb{'.exe' if sys.platform == 'win32' else ''}"
+            ),
             "-s",
             "exampleDevice",
             "example",
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
index 610a8647..6315f404 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
@@ -1,16 +1,29 @@
 import os
 import subprocess
+import sys
 
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
 
+from ....utils import create_file
+
+
+def create_emulator(root_path):
+    # Create `emulator` within `root_path`.
+    if sys.platform == "win32":
+        emulator_bin = "emulator.exe"
+    else:
+        emulator_bin = "emulator"
+
+    create_file(root_path / "emulator" / emulator_bin, "The Emulator", chmod=0o755)
+
 
 def test_succeeds_immediately_if_emulator_installed(mock_sdk):
     """`verify_emulator()` exits early if the emulator exists in its
     root_path."""
     # Create `emulator` within `root_path`.
-    (mock_sdk.root_path / "emulator").mkdir(parents=True)
+    create_emulator(mock_sdk.root_path)
 
     # Also create the platforms folder
     (mock_sdk.root_path / "platforms").mkdir(parents=True)
@@ -28,7 +41,7 @@ def test_succeeds_immediately_if_emulator_installed(mock_sdk):
 def test_creates_platforms_folder(mock_sdk):
     """If the platforms folder doesn't exist, it is created."""
     # Create `emulator` within `root_path`.
-    (mock_sdk.root_path / "emulator").mkdir(parents=True)
+    create_emulator(mock_sdk.root_path)
 
     # Verify the emulator. This should create the missing platforms folder.
     mock_sdk.verify_emulator()
@@ -59,6 +72,28 @@ def test_installs_android_emulator(mock_sdk):
     )
 
 
+def test_partial_android_emulator_install(mock_sdk):
+    """If the Android emulator is only partially installed, it's not
+    detected."""
+    # Create the emulator *directory*, but not the actual binary.
+    (mock_sdk.root_path / "emulator").mkdir(parents=True)
+
+    mock_sdk.verify_emulator()
+
+    # Platforms folder now exists
+    assert (mock_sdk.root_path / "platforms").exists()
+
+    mock_sdk.command.subprocess.run.assert_called_once_with(
+        [
+            os.fsdecode(mock_sdk.sdkmanager_path),
+            "platform-tools",
+            "emulator",
+        ],
+        env=mock_sdk.env,
+        check=True,
+    )
+
+
 def test_install_problems_are_reported(mock_sdk):
     """If the sdkmanager fails to properly install the Android emulator, an
     exception is raised."""
diff --git a/tests/integrations/android_sdk/conftest.py b/tests/integrations/android_sdk/conftest.py
index 1010a8a6..8845e05e 100644
--- a/tests/integrations/android_sdk/conftest.py
+++ b/tests/integrations/android_sdk/conftest.py
@@ -1,3 +1,4 @@
+import platform
 from pathlib import Path
 from unittest.mock import MagicMock
 
@@ -17,9 +18,8 @@ def mock_sdk(tmp_path):
     command.input = DummyConsole()
     command.logger = Log(verbosity=1)
 
-    # For default test purposes, assume we're on macOS x86_64
-    command.host_os = "Darwin"
-    command.host_arch = "x86_64"
+    command.host_arch = platform.machine()
+    command.host_os = platform.system()
 
     # Mock an empty environment
     command.os.environ = {}
diff --git a/tests/platforms/iOS/xcode/test_run.py b/tests/platforms/iOS/xcode/test_run.py
index 7302e9a6..ad7e1e31 100644
--- a/tests/platforms/iOS/xcode/test_run.py
+++ b/tests/platforms/iOS/xcode/test_run.py
@@ -95,7 +95,9 @@ def test_run_app_simulator_booted(first_app_config, tmp_path):
             "--style",
             "compact",
             "--predicate",
-            'senderImagePath ENDSWITH "/First App"',
+            'senderImagePath ENDSWITH "/First App"'
+            ' OR (processImagePath ENDSWITH "/First App"'
+            ' AND senderImagePath ENDSWITH "-iphonesimulator.so")',
         ],
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
@@ -199,7 +201,9 @@ def test_run_app_simulator_shut_down(first_app_config, tmp_path):
             "--style",
             "compact",
             "--predicate",
-            'senderImagePath ENDSWITH "/First App"',
+            'senderImagePath ENDSWITH "/First App"'
+            ' OR (processImagePath ENDSWITH "/First App"'
+            ' AND senderImagePath ENDSWITH "-iphonesimulator.so")',
         ],
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
@@ -316,7 +320,9 @@ def test_run_app_simulator_shutting_down(first_app_config, tmp_path):
             "--style",
             "compact",
             "--predicate",
-            'senderImagePath ENDSWITH "/First App"',
+            'senderImagePath ENDSWITH "/First App"'
+            ' OR (processImagePath ENDSWITH "/First App"'
+            ' AND senderImagePath ENDSWITH "-iphonesimulator.so")',
         ],
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
@@ -669,7 +675,9 @@ def test_run_app_simulator_launch_failure(first_app_config, tmp_path):
             "--style",
             "compact",
             "--predicate",
-            'senderImagePath ENDSWITH "/First App"',
+            'senderImagePath ENDSWITH "/First App"'
+            ' OR (processImagePath ENDSWITH "/First App"'
+            ' AND senderImagePath ENDSWITH "-iphonesimulator.so")',
         ],
         stdout=subprocess.PIPE,
         stderr=subprocess.STDOUT,
diff --git a/tests/utils.py b/tests/utils.py
index 0295996a..165c13e1 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,3 +1,4 @@
+import os
 import zipfile
 from unittest.mock import MagicMock
 
@@ -31,7 +32,7 @@ class FsPathMock(MagicMock):
         return MagicMock(**kw)
 
 
-def create_file(filepath, content, mode="w"):
+def create_file(filepath, content, mode="w", chmod=None):
     """A test utility to create a file with known content.
 
     Ensures that the directory for the file exists, and writes a file with
@@ -48,6 +49,9 @@ def create_file(filepath, content, mode="w"):
     with filepath.open(mode) as f:
         f.write(content)
 
+    if chmod:
+        os.chmod(filepath, chmod)
+
     return filepath