Repository: beeware/briefcase

Issue:
On Android, if your app is called `x.y.switch`, it fails to build
**Describe the bug**

If you call your app `switch` (or another Java reserved word), you will be unable to build it on Android.


**To Reproduce**

You can reproduce by creating an app whose module_name is `switch`. For example, you can use a Toga demo app.

Steps to reproduce the behavior:

1. Download the toga source code, then `cd` into `examples/switch`
2. Run `briefcase create android && briefcase build android`
3. Wait for the build to complete (about 60 seconds if you've used Briefcase on this system to make Android apps)
4. See an error:

```
> Task :app:processDebugResources FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:processDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
   > Package 'org.beeware.switch' from AndroidManifest.xml is not a valid Java package name as 'switch' is a Java keyword.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 9s
15 actionable tasks: 15 executed

Error while building project.
```

**Expected behavior**

The app should build or briefcase should print an error at the `briefcase new` phase, or briefcase should print an error before trying to run `briefcase create android`.


**Environment:**
 - Operating System: macOS 10.15
 - Python version: 3.7
 - Software versions:
   - Briefcase: 0.3.3
   - Toga: current git (0.3.0pre21)

**Additional context**
Add any other context about the problem here.


Comments:

Patch:
diff --git a/changes/460.bugfix.rst b/changes/460.bugfix.rst
new file mode 100644
index 00000000..bd578ac8
--- /dev/null
+++ b/changes/460.bugfix.rst
@@ -0,0 +1,1 @@
+Bundle identifiers are now validated to ensure they don't contain reserved words.
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 5d984b77..451abec7 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -7,14 +7,12 @@ from urllib.parse import urlparse
 
 from cookiecutter import exceptions as cookiecutter_exceptions
 
-from briefcase.config import is_valid_app_name
+from briefcase.config import is_valid_app_name, is_valid_bundle_identifier
 from briefcase.exceptions import NetworkFailure
 
 from .base import BaseCommand, BriefcaseCommandError
 from .create import InvalidTemplateRepository
 
-VALID_BUNDLE_RE = re.compile(r'[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$')
-
 
 def titlecase(s):
     """
@@ -138,12 +136,15 @@ class NewCommand(BaseCommand):
         :returns: True. If there are any validation problems, raises ValueError
             with a diagnostic message.
         """
-        if not VALID_BUNDLE_RE.match(candidate):
+        if not is_valid_bundle_identifier(candidate):
             raise ValueError(
-                "Bundle should be a reversed domain name. It must contain at "
-                "least 2 dot-separated sections, and each section may only "
-                "include letters, numbers, and hyphens."
+                f"{candidate!r} is not a valid bundle identifier.\n\n"
+                "The bundle should be a reversed domain name. It must contain at least 2\n"
+                "dot-separated sections; each section may only include letters, numbers,\n"
+                "and hyphens; and each section may not contain any reserved words (like\n"
+                "'switch', or 'while')."
             )
+
         return True
 
     def make_domain(self, bundle):
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 5fb26f9e..220af0d7 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -159,6 +159,21 @@ def is_valid_app_name(app_name):
     return False
 
 
+VALID_BUNDLE_RE = re.compile(r'[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$')
+
+
+def is_valid_bundle_identifier(bundle):
+    # Ensure the bundle identifier follows the basi
+    if not VALID_BUNDLE_RE.match(bundle):
+        return False
+
+    for part in bundle.split('.'):
+        if is_reserved_keyword(part):
+            return False
+
+    return True
+
+
 # This is the canonical definition from PEP440, modified to include named groups
 PEP440_CANONICAL_VERSION_PATTERN_RE = re.compile(
     r'^((?P<epoch>[1-9][0-9]*)!)?'
@@ -295,6 +310,15 @@ class AppConfig(BaseConfig):
                 "numbers, '-' and '_'; must start with a letter; and cannot end with '-' or '_')."
             )
 
+        if not is_valid_bundle_identifier(self.bundle):
+            raise BriefcaseConfigError(
+                f"{self.bundle!r} is not a valid bundle identifier.\n\n"
+                "The bundle should be a reversed domain name. It must contain at least 2\n"
+                "dot-separated sections; each section may only include letters, numbers,\n"
+                "and hyphens; and each section may not contain any reserved words (like\n"
+                "'switch', or 'while')."
+            )
+
         # Version number is PEP440 compliant:
         if not is_pep440_canonical_version(self.version):
             raise BriefcaseConfigError(


Test patch:
diff --git a/tests/commands/new/test_validate_bundle.py b/tests/commands/new/test_validate_bundle.py
index a07eb574..112399f3 100644
--- a/tests/commands/new/test_validate_bundle.py
+++ b/tests/commands/new/test_validate_bundle.py
@@ -23,6 +23,7 @@ def test_valid_bundle(new_command, bundle):
         'com.hello_world',  # underscore
         'com.hello,world',  # comma
         'com.hello world!',  # exclamation point
+        'com.pass.example',  # Reserved word
     ]
 )
 def test_invalid_bundle(new_command, bundle):
diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py
index 4e7e8f6e..9c39b4ca 100644
--- a/tests/config/test_AppConfig.py
+++ b/tests/config/test_AppConfig.py
@@ -133,6 +133,53 @@ def test_invalid_app_name(name):
         )
 
 
+@pytest.mark.parametrize(
+    'bundle',
+    [
+        'com.example',
+        'com.example.more',
+        'com.example42.more',
+        'com.example-42.more',
+    ]
+)
+def test_valid_bundle(bundle):
+    try:
+        AppConfig(
+            app_name='myapp',
+            version="1.2.3",
+            bundle=bundle,
+            description="A simple app",
+            sources=['src/myapp']
+        )
+    except BriefcaseConfigError:
+        pytest.fail(f'{bundle} should be valid')
+
+
+@pytest.mark.parametrize(
+    'bundle',
+    [
+        'not a bundle!',  # Free text.
+        'home',  # Only one section.
+        'com.hello_world',  # underscore
+        'com.hello,world',  # comma
+        'com.hello world!',  # exclamation point
+        'com.pass',  # Python reserved word
+        'com.pass.example',  # Python reserved word
+        'com.switch',  # Java reserved word
+        'com.switch.example',  # Java reserved word
+    ]
+)
+def test_invalid_bundle_identifier(bundle):
+    with pytest.raises(BriefcaseConfigError, match=r"is not a valid bundle identifier\."):
+        AppConfig(
+            app_name="myapp",
+            version="1.2.3",
+            bundle=bundle,
+            description="A simple app",
+            sources=['src/invalid']
+        )
+
+
 def test_valid_app_version():
     try:
         AppConfig(
diff --git a/tests/config/test_is_valid_bundle_identifier.py b/tests/config/test_is_valid_bundle_identifier.py
new file mode 100644
index 00000000..0ad9f5fb
--- /dev/null
+++ b/tests/config/test_is_valid_bundle_identifier.py
@@ -0,0 +1,36 @@
+import pytest
+
+from briefcase.config import is_valid_bundle_identifier
+
+
+@pytest.mark.parametrize(
+    'bundle',
+    [
+        'com.example',
+        'com.example.more',
+        'com.example42.more',
+        'com.example-42.more',
+    ]
+)
+def test_valid_bundle(bundle):
+    "Test that valid bundles are accepted"
+    assert is_valid_bundle_identifier(bundle)
+
+
+@pytest.mark.parametrize(
+    'bundle',
+    [
+        'not a bundle!',  # Free text.
+        'home',  # Only one section.
+        'com.hello_world',  # underscore
+        'com.hello,world',  # comma
+        'com.hello world!',  # exclamation point
+        'com.pass',  # Python reserved word
+        'com.pass.example',  # Python reserved word
+        'com.switch',  # Java reserved word
+        'com.switch.example',  # Java reserved word
+    ]
+)
+def test_invalid_bundle(bundle):
+    "Test that invalid bundles are rejected"
+    assert not is_valid_bundle_identifier(bundle)