Repository: beeware/briefcase
Pull request #2201 (2025-03-18 00:56:04)

Add --app option to briefcase build/package (#2148)
This pull request introduces the `-a / --app` option for `briefcase build` and `briefcase package`, allowing users to specify which app(s) to build/package instead of processing all apps in a project.

Allow specifying app name(s) to briefcase build and briefcase package #2148

## 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



Patch:
diff --git a/changes/1520.bugfix.rst b/changes/1520.bugfix.rst
new file mode 100644
index 00000000..07815012
--- /dev/null
+++ b/changes/1520.bugfix.rst
@@ -0,0 +1,1 @@
+Error handling during JDK upgrades has been improved.
diff --git a/changes/2103.feature.rst b/changes/2103.feature.rst
new file mode 100644
index 00000000..561ea064
--- /dev/null
+++ b/changes/2103.feature.rst
@@ -0,0 +1,1 @@
+Cookiecutter filters for escaping XML content and attributes have been added.
diff --git a/changes/2128.feature.rst b/changes/2128.feature.rst
new file mode 100644
index 00000000..ad493044
--- /dev/null
+++ b/changes/2128.feature.rst
@@ -0,0 +1,1 @@
+It's now possible to ask boolean question using the Console Interface.
diff --git a/changes/2148.feature.rst b/changes/2148.feature.rst
new file mode 100644
index 00000000..de19ab50
--- /dev/null
+++ b/changes/2148.feature.rst
@@ -0,0 +1,1 @@
+The `build` and `package` commands can now be run on a single app within a multi-app project by using the `-a / --app` option.
diff --git a/changes/2204.removal.rst b/changes/2204.removal.rst
new file mode 100644
index 00000000..d8f14517
--- /dev/null
+++ b/changes/2204.removal.rst
@@ -0,0 +1,1 @@
+The ``app_packages`` folder now occurs *after* the ``app`` folder in the package resolution path on Windows, Linux, macOS and iOS. This will result in subtle changes in behavior if you have packages defined in your ``sources``  that have the same name as packages installed from your ``requires``.
diff --git a/changes/381.bugfix.rst b/changes/381.bugfix.rst
new file mode 100644
index 00000000..59042d48
--- /dev/null
+++ b/changes/381.bugfix.rst
@@ -0,0 +1,1 @@
+``.pth`` files created by packages installed as dependencies are now correctly processed during application startup on macOS, Windows, Linux and iOS.
diff --git a/docs/reference/commands/build.rst b/docs/reference/commands/build.rst
index 45692529..b1fd77dc 100644
--- a/docs/reference/commands/build.rst
+++ b/docs/reference/commands/build.rst
@@ -43,6 +43,13 @@ Options
 
 The following options can be provided at the command line.
 
+``-a <app name>`` / ``--app <app name>``
+----------------------------------------
+
+Run a specific application target in your project. This argument is only
+required if your project contains more than one application target. The app
+name specified should be the machine-readable package name for the app.
+
 ``-u`` / ``--update``
 ---------------------
 
diff --git a/docs/reference/commands/package.rst b/docs/reference/commands/package.rst
index cfa08391..23d012c8 100644
--- a/docs/reference/commands/package.rst
+++ b/docs/reference/commands/package.rst
@@ -41,6 +41,13 @@ Options
 
 The following options can be provided at the command line.
 
+``-a <app name>`` / ``--app <app name>``
+----------------------------------------
+
+Run a specific application target in your project. This argument is only
+required if your project contains more than one application target. The app
+name specified should be the machine-readable package name for the app.
+
 ``-u`` / ``--update``
 ---------------------
 
diff --git a/docs/reference/commands/run.rst b/docs/reference/commands/run.rst
index 7dd34ec4..86912bb9 100644
--- a/docs/reference/commands/run.rst
+++ b/docs/reference/commands/run.rst
@@ -60,8 +60,8 @@ Options
 
 The following options can be provided at the command line.
 
-``-a <app name>`` / ``--app <app name``
----------------------------------------
+``-a <app name>`` / ``--app <app name>``
+----------------------------------------
 
 Run a specific application target in your project. This argument is only
 required if your project contains more than one application target. The app
diff --git a/docs/reference/commands/update.rst b/docs/reference/commands/update.rst
index 977466f1..9eb1cba9 100644
--- a/docs/reference/commands/update.rst
+++ b/docs/reference/commands/update.rst
@@ -36,6 +36,13 @@ Options
 
 The following options can be provided at the command line.
 
+``-a <app name>`` / ``--app <app name>``
+----------------------------------------
+
+Run a specific application target in your project. This argument is only
+required if your project contains more than one application target. The app
+name specified should be the machine-readable package name for the app.
+
 ``-r`` / ``--update-requirements``
 ----------------------------------
 
diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py
index f33271cb..59b92b99 100644
--- a/src/briefcase/commands/build.py
+++ b/src/briefcase/commands/build.py
@@ -14,6 +14,13 @@ class BuildCommand(BaseCommand):
         self._add_update_options(parser, context_label=" before building")
         self._add_test_options(parser, context_label="Build")
 
+        parser.add_argument(
+            "-a",
+            "--app",
+            dest="app_name",
+            help="Name of the app to build (if multiple apps exist in the project)",
+        )
+
     def build_app(self, app: AppConfig, **options):
         """Build an application.
 
@@ -86,6 +93,7 @@ class BuildCommand(BaseCommand):
     def __call__(
         self,
         app: AppConfig | None = None,
+        app_name: str | None = None,
         update: bool = False,
         update_requirements: bool = False,
         update_resources: bool = False,
@@ -123,9 +131,22 @@ class BuildCommand(BaseCommand):
         # and that the app configuration is finalized.
         self.finalize(app)
 
-        if app:
+        if app_name:
+            try:
+                apps_to_build = {app_name: self.apps[app_name]}
+            except KeyError:
+                raise BriefcaseCommandError(
+                    f"App '{app_name}' does not exist in this project."
+                )
+        elif app:
+            apps_to_build = {app.app_name: app}
+        else:
+            apps_to_build = self.apps
+
+        state = None
+        for app_name, app_obj in sorted(apps_to_build.items()):
             state = self._build_app(
-                app,
+                app_obj,
                 update=update,
                 update_requirements=update_requirements,
                 update_resources=update_resources,
@@ -133,21 +154,7 @@ class BuildCommand(BaseCommand):
                 update_stub=update_stub,
                 no_update=no_update,
                 test_mode=test_mode,
-                **options,
+                **full_options(state, options),
             )
-        else:
-            state = None
-            for app_name, app in sorted(self.apps.items()):
-                state = self._build_app(
-                    app,
-                    update=update,
-                    update_requirements=update_requirements,
-                    update_resources=update_resources,
-                    update_support=update_support,
-                    update_stub=update_stub,
-                    no_update=no_update,
-                    test_mode=test_mode,
-                    **full_options(state, options),
-                )
 
         return state
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 2ac82d61..5abaa333 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from abc import abstractmethod
 
 from briefcase.config import AppConfig
+from briefcase.exceptions import BriefcaseCommandError
 
 from .base import BaseCommand, full_options
 
@@ -118,6 +119,13 @@ class PackageCommand(BaseCommand):
         return state
 
     def add_options(self, parser):
+        parser.add_argument(
+            "-a",
+            "--app",
+            dest="app_name",
+            help="Name of the app to package (if multiple apps exist in the project)",
+        )
+
         parser.add_argument(
             "-u",
             "--update",
@@ -151,20 +159,41 @@ class PackageCommand(BaseCommand):
     def __call__(
         self,
         app: AppConfig | None = None,
+        app_name: str | None = None,
         update: bool = False,
         **options,
     ) -> dict | None:
+
         # Confirm host compatibility, that all required tools are available,
         # and that the app configuration is finalized.
         self.finalize(app)
 
-        if app:
-            state = self._package_app(app, update=update, **options)
-        else:
-            state = None
-            for app_name, app in sorted(self.apps.items()):
-                state = self._package_app(
-                    app, update=update, **full_options(state, options)
+        if app_name:
+            try:
+                apps_to_package = {app_name: self.apps[app_name]}
+            except KeyError:
+                raise BriefcaseCommandError(
+                    f"App '{app_name}' does not exist in this project."
                 )
+        elif app:
+            apps_to_package = {app.app_name: app}
+        else:
+            apps_to_package = self.apps
+
+        state = None
+        for app_name, app_obj in sorted(apps_to_package.items()):
+            state = self._package_app(
+                app_obj,
+                update=update,
+                **full_options(state, options),
+            )
 
         return state
+
+    def parse_options(self, extra=None):
+        options, overrides = super().parse_options(extra=extra)
+
+        if options.get("app_name") is None:
+            options.pop("app_name", None)
+
+        return options, overrides
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 2c245870..607f889d 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -201,6 +201,24 @@ def parsed_version(version):
     return SimpleNamespace(**groupdict)
 
 
+def parse_boolean(value: str) -> bool:
+    """Takes a string value and attempts to convert to a boolean value"""
+
+    truth_vals = {"true", "t", "yes", "y", "1", "on"}
+    false_vals = {"false", "f", "no", "n", "0", "off"}
+
+    normalised_val = value.strip().lower()
+
+    if normalised_val in truth_vals:
+        return True
+    elif normalised_val in false_vals:
+        return False
+    else:
+        raise ValueError(
+            f"Invalid boolean value: {value!r}. Expected one of {truth_vals | false_vals}"
+        )
+
+
 class BaseConfig:
     def __init__(self, **kwargs):
         for attr, value in kwargs.items():
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index 9384daf9..69df38b3 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -30,6 +30,7 @@ from rich.progress import (
 from rich.traceback import Trace, Traceback
 
 from briefcase import __version__
+from briefcase.config import parse_boolean
 from briefcase.exceptions import InputDisabled
 
 # Max width for printing to console; matches argparse's default width
@@ -732,7 +733,7 @@ class Console:
         :returns: True if the user selected "y", or False if they selected "n".
         """
         if default is None:
-            yes_no = "y/n"
+            yes_no = "[y/n]"
             default_text = None
         elif default:
             yes_no = "[Y/n]"
@@ -960,3 +961,39 @@ class Console:
         )
 
         return ordered[int(index) - 1][0]
+
+    def boolean_question(
+        self,
+        description,
+        intro: str,
+        default: bool | None = None,
+        override_value: str | None = None,
+    ) -> bool:
+        """Ask the user a boolean question who's answer requires selecting yes/no
+
+        :param description: A short description of the question being asked. This text
+            is used in prompts and a header bar prefacing the question.
+        :param intro: An introductory paragraph explaining the question being asked.
+        :param default: The default option for empty user input.
+        :param override_value: A pre-selected answer for the question. This can be used
+            to shortcut asking the question, such as when a command line option provides
+            a value. If provided and valid, the header bar will be displayed, but the
+            intro paragraph and option list will not. Will take the provided string and attempt to parse into bool
+        :returns: The user's chosen answer or none if closed without input
+        """
+
+        self.divider(title=description)
+
+        if override_value is not None:
+            self.print()
+            self.print(f"Using override value {override_value!r}")
+            try:
+                return parse_boolean(override_value)
+            except ValueError as e:
+                raise ValueError(f"Invalid override value for {description}: {e}")
+
+        self.prompt()
+        self.prompt(self.textwrap(intro))
+        self.prompt()
+
+        return self.input_boolean(description, default=default)
diff --git a/src/briefcase/integrations/cookiecutter.py b/src/briefcase/integrations/cookiecutter.py
index d691b289..b9ac4540 100644
--- a/src/briefcase/integrations/cookiecutter.py
+++ b/src/briefcase/integrations/cookiecutter.py
@@ -1,6 +1,7 @@
 """Jinja2 extensions."""
 
 import uuid
+from xml.sax.saxutils import escape, quoteattr
 
 from jinja2.ext import Extension
 
@@ -139,7 +140,17 @@ class XMLExtension(Extension):
             """Render value in XML format appropriate for an attribute."""
             return "true" if obj else "false"
 
+        def xml_escape(obj):
+            """Filter to escape characters <, >, &, " and '"""
+            return escape(obj)
+
+        def xml_attr(obj):
+            """ "Filter to quote an XML value appropriately."""
+            return quoteattr(obj)
+
         environment.filters["bool_attr"] = bool_attr
+        environment.filters["xml_escape"] = xml_escape
+        environment.filters["xml_attr"] = xml_attr
 
 
 class UUIDExtension(Extension):
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 7e0684a7..e363ec7d 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -310,8 +310,19 @@ Delete {jdk_zip_path} and run briefcase again.
 
     def uninstall(self):
         """Uninstall a JDK."""
-        with self.tools.console.wait_bar("Removing old JDK install..."):
-            if self.tools.host_os == "Darwin":
-                self.tools.shutil.rmtree(self.java_home.parent.parent)
-            else:
-                self.tools.shutil.rmtree(self.java_home)
+        try:
+            with self.tools.console.wait_bar("Removing old JDK install..."):
+                if self.tools.host_os == "Darwin":
+                    jdk_location = self.java_home.parent.parent
+                else:
+                    jdk_location = self.java_home
+                self.tools.shutil.rmtree(jdk_location)
+
+        except PermissionError as e:
+            raise BriefcaseCommandError(
+                f"""\
+Permission denied when trying to remove {jdk_location!r}
+
+Ensure no Java processes are running and try again.
+"""
+            ) from e


Test patch:
diff --git a/tests/commands/build/test_call.py b/tests/commands/build/test_call.py
index ee1d4ca9..f70be9f2 100644
--- a/tests/commands/build/test_call.py
+++ b/tests/commands/build/test_call.py
@@ -1217,3 +1217,127 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app):
             {"update_state": "second", "build_state": "first", "test_mode": True},
         ),
     ]
+
+
+# Parametrize both --apps/-a flags
+@pytest.mark.parametrize("app_flags", ["--app", "-a"])
+def test_build_app_single(build_command, first_app, second_app, app_flags):
+    """If the --app or -a flag is used, only the selected app is built."""
+    # Add two apps
+    build_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure command line options with the parameterized flag
+    options, _ = build_command.parse_options([app_flags, "first"])
+
+    # Run the build command
+    build_command(**options)
+
+    # Only the selected app is built
+    assert build_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App configs have been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
+        # App tools are verified for first app
+        ("verify-app-tools", "first"),
+        # Build the first app
+        ("build", "first", {"test_mode": False}),
+    ]
+
+
+def test_build_app_invalid(build_command, first_app, second_app):
+    """If an invalid app name is passed to --app, an error is raised."""
+    # Add two valid apps
+    build_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure command line options
+    options, _ = build_command.parse_options(["--app", "invalid"])
+
+    # Run the build command
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"App 'invalid' does not exist in this project.",
+    ):
+        build_command(**options)
+
+
+def test_build_app_none_defined(build_command):
+    """If no apps are defined, do nothing."""
+    # No apps available
+    build_command.apps = {}
+
+    # Configure command line options
+    options, _ = build_command.parse_options([])
+
+    # Run the build command
+    result = build_command(**options)
+
+    # Nothing is built
+    assert result is None
+    assert build_command.actions == [("verify-host",), ("verify-tools",)]
+
+
+def test_build_app_all_flags(build_command, first_app, second_app):
+    """Verify that all build-related update flags work correctly with -a."""
+    # Add two apps
+    build_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure command line with all available flags
+    options, _ = build_command.parse_options(
+        [
+            "-a",
+            "first",
+            "--test",
+            "--update",
+            "--update-requirements",
+            "--update-resources",
+            "--update-support",
+            "--update-stub",
+        ]
+    )
+
+    # Run the build command
+    build_command(**options)
+
+    # The right sequence of things will be done
+    assert build_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App configs have been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # First app is updated with all update flags
+        (
+            "update",
+            "first",
+            {
+                "test_mode": True,
+                "update_requirements": True,
+                "update_resources": True,
+                "update_support": True,
+                "update_stub": True,
+            },
+        ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
+        # App tools are verified for first app
+        ("verify-app-tools", "first"),
+        # First app is built in test mode
+        ("build", "first", {"update_state": "first", "test_mode": True}),
+    ]
diff --git a/tests/commands/package/test_call.py b/tests/commands/package/test_call.py
index ecb5ab1a..b8bfd969 100644
--- a/tests/commands/package/test_call.py
+++ b/tests/commands/package/test_call.py
@@ -1,3 +1,7 @@
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
 from ...utils import create_file
 
 
@@ -758,3 +762,157 @@ def test_already_packaged(package_command, first_app, tmp_path):
     # package_app() call, no new artefact is created; the absence
     # of this file shows that the old one has been deleted.
     assert not artefact_path.exists()
+
+
+# Parametrize both --apps/-a flags
+@pytest.mark.parametrize("app_flags", ["--app", "-a"])
+def test_package_app_single(package_command, first_app, second_app, app_flags):
+    """If the --app or -a flag is used, only the selected app is packaged."""
+    # Add two apps
+    package_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure command line options with the parameterized flag
+    options, _ = package_command.parse_options([app_flags, "first"])
+
+    # Run the package command
+    package_command(**options)
+
+    # Only the selected app is packaged
+    assert package_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # App template is verified
+        ("verify-app-template", "first"),
+        # App tools are verified
+        ("verify-app-tools", "first"),
+        # Package the app
+        (
+            "package",
+            "first",
+            {
+                "adhoc_sign": False,
+                "identity": None,
+            },
+        ),
+    ]
+
+
+def test_package_app_invalid(package_command, first_app, second_app):
+    """If an invalid app name is passed to --app, raise an error."""
+    # Add two apps
+    package_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure the --app option with an invalid app
+    options, _ = package_command.parse_options(["--app", "invalid"])
+
+    # Running the command should raise an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"App 'invalid' does not exist in this project.",
+    ):
+        package_command(**options)
+
+
+def test_package_app_none_defined(package_command):
+    """If no apps are defined, do nothing."""
+    # No apps defined
+    package_command.apps = {}
+
+    # Configure no command line options
+    options, _ = package_command.parse_options([])
+
+    # Run the package command
+    result = package_command(**options)
+
+    # The right sequence of things will be done
+    assert result is None
+    assert package_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+    ]
+
+
+def test_package_app_all_flags(package_command, first_app, second_app):
+    """Verify that all package-related flags work correctly with -a."""
+    # Add a single app
+    package_command.apps = {
+        "first": first_app,
+        "second": second_app,
+    }
+
+    # Configure command line options with all available flags
+    options, _ = package_command.parse_options(
+        [
+            "-a",
+            "first",
+            "--update",
+            "--packaging-format",
+            "pkg",
+            "--adhoc-sign",
+        ]
+    )
+    app_name = options.pop("app_name")
+
+    # Run the package command
+    package_command(app_name=app_name, **options)
+
+    # The right sequence of things will be done
+    assert package_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        ("finalize-app-config", "second"),
+        # App is updated with all update options
+        (
+            "update",
+            "first",
+            {
+                "adhoc_sign": True,
+                "identity": None,
+                "update_requirements": True,
+                "update_resources": True,
+                "update_support": True,
+            },
+        ),
+        # App is built with update state
+        (
+            "build",
+            "first",
+            {
+                "adhoc_sign": True,
+                "identity": None,
+                "update_state": "first",
+            },
+        ),
+        # App template is verified
+        ("verify-app-template", "first"),
+        # App tools are verified
+        ("verify-app-tools", "first"),
+        # App is packaged with the full state and adhoc signing
+        (
+            "package",
+            "first",
+            {
+                "adhoc_sign": True,
+                "identity": None,
+                "update_state": "first",
+                "build_state": "first",
+            },
+        ),
+    ]
diff --git a/tests/config/test_parse_boolean.py b/tests/config/test_parse_boolean.py
new file mode 100644
index 00000000..a022bd7d
--- /dev/null
+++ b/tests/config/test_parse_boolean.py
@@ -0,0 +1,33 @@
+import pytest
+
+from briefcase.config import parse_boolean
+
+
+@pytest.mark.parametrize(
+    "user_input_val",
+    ["true", "TRUE", "t", "T", "yes", "YES", "y", "Y", "1", "on", "ON"],
+)
+def test_true_values(user_input_val):
+    assert parse_boolean(user_input_val) is True
+
+
+@pytest.mark.parametrize(
+    "user_input_val",
+    ["false", "FALSE", "f", "F", "no", "NO", "n", "N", "0", "off", "OFF"],
+)
+def test_false_values(user_input_val):
+    assert parse_boolean(user_input_val) is False
+
+
+@pytest.mark.parametrize("user_input_val", ["maybe", "2", "", "help"])
+def test_invalid_values(user_input_val):
+    with pytest.raises(ValueError):
+        parse_boolean(user_input_val)
+
+
+@pytest.mark.parametrize(
+    "user_input_val, expected",
+    [("         yEs ", True), ("nO          ", False), ("    YEs", True)],
+)
+def test_whitespace(user_input_val, expected):
+    assert parse_boolean(user_input_val) is expected
diff --git a/tests/console/Console/test_boolean_question.py b/tests/console/Console/test_boolean_question.py
new file mode 100644
index 00000000..b55b889c
--- /dev/null
+++ b/tests/console/Console/test_boolean_question.py
@@ -0,0 +1,108 @@
+import re
+
+import pytest
+
+from tests.utils import DummyConsole
+
+
+def test_boolean_question_yes():
+    """Test that boolean_question returns True when user selects Yes."""
+    console = DummyConsole("y")
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+    )
+
+    assert console.prompts == ["Confirm? [y/n]? "]
+    assert result is True
+
+
+def test_boolean_question_no():
+    """Test that boolean_question returns False when user selects No."""
+    console = DummyConsole("n")
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+    )
+
+    assert console.prompts == ["Confirm? [y/n]? "]
+    assert result is False
+
+
+@pytest.mark.parametrize(
+    "default, expected, prompt",
+    [
+        (True, True, "Confirm? [Y/n]? "),
+        (False, False, "Confirm? [y/N]? "),
+    ],
+)
+def test_boolean_question_default_used(default, expected, prompt):
+    """If no input is provided, the default value should be used."""
+    console = DummyConsole("")
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=default,
+    )
+
+    assert console.prompts == [prompt]
+    assert result == expected
+
+
+@pytest.mark.parametrize(
+    "override_value, expected_result",
+    [
+        ("Yes", True),
+        ("no", False),
+    ],
+)
+def test_boolean_question_override_used(capsys, override_value, expected_result):
+    """The override is used if provided and valid (parametrized for True/False cases)."""
+    console = DummyConsole()
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+        override_value=override_value,
+    )
+
+    output = capsys.readouterr().out
+    assert f"Using override value {override_value!r}" in output
+    assert result == expected_result
+    assert console.prompts == []
+
+
+def test_boolean_question_override_invalid():
+    """If override_value is invalid, an error should be raised immediately."""
+    console = DummyConsole()
+
+    with pytest.raises(
+        ValueError,
+        match=re.escape("Invalid boolean value: 'Yeah Nah'. Expected one of "),
+    ):
+        console.boolean_question(
+            description="Confirm?",
+            intro="Are you sure?",
+            default=True,
+            override_value="Yeah Nah",
+        )
+
+
+def test_boolean_question_empty_input_no_default_returns_false():
+    """Reprompts the user when no override or default given"""
+    console = DummyConsole("", "", "n")  # Simulates pressing Enter
+
+    result = console.boolean_question(
+        description="Confirm?",
+        intro="Are you sure?",
+        default=None,
+        override_value=None,
+    )
+
+    assert result is False
diff --git a/tests/integrations/cookiecutter/test_XMLExtension.py b/tests/integrations/cookiecutter/test_XMLExtension.py
index 54b256a4..7b3199b1 100644
--- a/tests/integrations/cookiecutter/test_XMLExtension.py
+++ b/tests/integrations/cookiecutter/test_XMLExtension.py
@@ -24,3 +24,41 @@ def test_bool_attr(value, expected):
     env.filters = {}
     XMLExtension(env)
     assert env.filters["bool_attr"](value) == expected
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        # No special characters
+        ("Hello World", "Hello World"),
+        # Ampersands escaped
+        ("Hello & World", "Hello &amp; World"),
+        # Less than
+        ("Hello < World", "Hello &lt; World"),
+        # Greater than
+        ("Hello > World", "Hello &gt; World"),
+    ],
+)
+def test_xml_escape(value, expected):
+    env = MagicMock()
+    env.filters = {}
+    XMLExtension(env)
+    assert env.filters["xml_escape"](value) == expected
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        # A single quote wrapped in double quotes
+        ("Hello ' World", '"Hello \' World"'),
+        # A double quote wrapped in single quotes
+        ('Hello " World', "'Hello \" World'"),
+        # A double quote and a single quote in the same string value
+        ("Hello \" And ' World", '"Hello &quot; And \' World"'),
+    ],
+)
+def test_xml_attr(value, expected):
+    env = MagicMock()
+    env.filters = {}
+    XMLExtension(env)
+    assert env.filters["xml_attr"](value) == expected
diff --git a/tests/integrations/java/test_JDK__uninstall.py b/tests/integrations/java/test_JDK__uninstall.py
new file mode 100644
index 00000000..efd94353
--- /dev/null
+++ b/tests/integrations/java/test_JDK__uninstall.py
@@ -0,0 +1,83 @@
+import re
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+)
+from briefcase.integrations.java import JDK
+
+
+@pytest.mark.parametrize(
+    "host_os, java_home",
+    [
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
+    ],
+)
+def test_java_running(mock_tools, host_os, java_home, tmp_path):
+    """If Java is running, uninstall will raise an error message."""
+    # Mock os
+    mock_tools.host_os = host_os
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / java_home / "bin").mkdir(parents=True)
+
+    # Create an JDK wrapper
+    jdk = JDK(mock_tools, java_home=tmp_path / java_home)
+
+    # Mock shutil.rmtree so that it gives a permission error
+    mock_tools.shutil.rmtree.side_effect = PermissionError(
+        "File is being used by other processes"
+    )
+
+    # Set correct path according to each OS
+    if host_os == "Darwin":
+        expected_loc = (tmp_path / java_home).parent.parent
+    else:
+        expected_loc = tmp_path / java_home
+
+    # Attempting to uninstall JDK which will fail due to Permission Error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=re.escape(f"Permission denied when trying to remove {expected_loc!r}"),
+    ):
+        jdk.uninstall()
+
+
+@pytest.mark.parametrize(
+    "host_os, java_home",
+    [
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
+    ],
+)
+def test_java_not_running(mock_tools, host_os, java_home, tmp_path):
+    """If Java is not running, uninstalling should proceed smoothly."""
+    # Mock os
+    mock_tools.host_os = host_os
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / java_home / "bin").mkdir(parents=True)
+
+    # Create an JDK wrapper
+    jdk = JDK(mock_tools, java_home=tmp_path / java_home)
+
+    # Set correct path according to each OS
+    if host_os == "Darwin":
+        expected_loc = (tmp_path / java_home).parent.parent
+    else:
+        expected_loc = tmp_path / java_home
+
+    # Mock so that shutil behave normally
+    mock_tools.shutil.rmtree = MagicMock()
+
+    # Uninstall JDK
+    jdk.uninstall()
+
+    # Verify that shutil works normally and that correct location is passed
+    mock_tools.shutil.rmtree.assert_called_once_with(expected_loc)