Repository: beeware/briefcase

Issue:
Bump OpenJDK Version
### Describe the bug

The JDK version has been superseded; putting this out there to consider bumping it.

`src/briefcase/integrations/java.py`
```
        # As of April 10 2020, 8u242-b08 is the current AdoptOpenJDK
        # https://adoptopenjdk.net/releases.html
        self.release = "8u242"
        self.build = "b08"
```

Version `jdk8u362-b09` is available.
Additionally, version 11 is also LTS.

There is also this banner at the [website](https://adoptopenjdk.net/releases.html):
24th July 2021: [AdoptOpenJDK is moving to the Eclipse Foundation](https://blog.adoptopenjdk.net/2021/03/transition-to-eclipse-an-update/) and rebranding.
Our July 2021 and future releases will come from [Adoptium.net](https://www.adoptium.net/)

### Steps to reproduce

`briefcase run android` will require the jdk.

### Expected behavior

Not entirely sure....but it seems like we want to avoid the JDK getting too old.

### Screenshots

_No response_

### Environment

- Operating System: pop os 22.04
- Python version: 3.11
- Software versions:
  - Briefcase: `0.3.12.dev359+gcf18ae8c.d20230128`


### Logs

_No response_

### Additional context

_No response_

Discussion:
rmartin16: Adding `--add-opens=java.base/java.io=ALL-UNNAMED` to `jvmargs` in `gradle-wrapper.properties` let the build finish for me.

Patch:
diff --git a/changes/1065.feature.rst b/changes/1065.feature.rst
new file mode 100644
index 00000000..cdeabec7
--- /dev/null
+++ b/changes/1065.feature.rst
@@ -0,0 +1,1 @@
+Android support was upgraded to use Java 17 for builds.
diff --git a/changes/1065.removal.rst b/changes/1065.removal.rst
new file mode 100644
index 00000000..9063bd9e
--- /dev/null
+++ b/changes/1065.removal.rst
@@ -0,0 +1,1 @@
+The version of OpenJDK for Java was updated from 8 to 17. Any Android apps generated on previous versions of Briefcase must be re-generated by running ``briefcase create android gradle``. If customizations were made to files within the generated app, they will need to be manually re-applied after re-running the create command.
diff --git a/changes/1112.bugfix.rst b/changes/1112.bugfix.rst
new file mode 100644
index 00000000..bc97677c
--- /dev/null
+++ b/changes/1112.bugfix.rst
@@ -0,0 +1,1 @@
+When the local Android keystore was generated with a recent version of java, Briefcase would raise an "Invalid Keystore format" error during app signing. This error will no longer occur.
diff --git a/changes/827.bugfix.rst b/changes/827.bugfix.rst
new file mode 100644
index 00000000..3f521e10
--- /dev/null
+++ b/changes/827.bugfix.rst
@@ -0,0 +1,1 @@
+XML compatibility warnings generated by the Android build have been cleaned up.
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index d7e250bf..d8d82d7f 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import argparse
 import importlib
 import inspect
@@ -9,7 +11,6 @@ import textwrap
 from abc import ABC, abstractmethod
 from argparse import RawDescriptionHelpFormatter
 from pathlib import Path
-from typing import Optional
 
 from cookiecutter import exceptions as cookiecutter_exceptions
 from cookiecutter.repository import is_repo_url
@@ -26,12 +27,13 @@ except ModuleNotFoundError:  # pragma: no-cover-if-gte-py310
     import tomli as tomllib
 
 from briefcase import __version__
-from briefcase.config import AppConfig, BaseConfig, GlobalConfig, parse_config
+from briefcase.config import AppConfig, GlobalConfig, parse_config
 from briefcase.console import Console, Log
 from briefcase.exceptions import (
     BriefcaseCommandError,
     BriefcaseConfigError,
     InvalidTemplateRepository,
+    MissingAppMetadata,
     NetworkFailure,
     TemplateUnsupportedVersion,
     UnsupportedHostError,
@@ -105,9 +107,17 @@ class BaseCommand(ABC):
     cmd_line = "briefcase {command} {platform} {output_format}"
     supported_host_os = {"Darwin", "Linux", "Windows"}
     supported_host_os_reason = f"This command is not supported on {platform.system()}."
-    GLOBAL_CONFIG_CLASS = GlobalConfig
-    APP_CONFIG_CLASS = AppConfig
+    # defined by platform-specific subclasses
+    command: str
+    description: str
+    platform: str
+    output_format: str
+    # supports passing extra command line arguments to subprocess
     allows_passthrough = False
+    # if specified for a platform, then any template for that platform must declare
+    # compatibility with that version epoch. An epoch begins when a breaking change is
+    # introduced for a platform such that older versions of a template are incompatible
+    platform_target_version: str | None = None
 
     def __init__(
         self,
@@ -149,7 +159,7 @@ class BaseCommand(ABC):
         Download.verify(tools=self.tools)
 
         self.global_config = None
-        self._path_index = {}
+        self._briefcase_toml: dict[AppConfig, dict[str, ...]] = {}
 
     @property
     def logger(self):
@@ -287,7 +297,7 @@ a custom location for Briefcase's tools.
         """Publish Command factory for the same platform and format."""
         return self._command_factory("publish")
 
-    def build_path(self, app):
+    def build_path(self, app) -> Path:
         """The path in which all platform artefacts for the app will be built.
 
         :param app: The app config
@@ -295,11 +305,11 @@ a custom location for Briefcase's tools.
         return self.base_path / "build" / app.app_name / self.platform.lower()
 
     @property
-    def dist_path(self):
+    def dist_path(self) -> Path:
         """The path for all applications for this command's platform."""
         return self.base_path / "dist"
 
-    def bundle_path(self, app):
+    def bundle_path(self, app) -> Path:
         """The path to the bundle for the app in the output format.
 
         The bundle is the template-generated source form of the app.
@@ -313,7 +323,7 @@ a custom location for Briefcase's tools.
         return self.build_path(app) / self.output_format.lower()
 
     @abstractmethod
-    def binary_path(self, app):
+    def binary_path(self, app) -> Path:
         """The path to the executable artefact for the app in the output format.
 
         This may be a binary file produced by compilation; however, if
@@ -324,50 +334,68 @@ a custom location for Briefcase's tools.
 
         :param app: The app config
         """
-        ...
 
-    def _load_path_index(self, app: BaseConfig):
-        """Load the path index from the index file provided by the app template.
+    def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
+        """Load the ``briefcase.toml`` file provided by the app template.
 
         :param app: The config object for the app
-        :return: The contents of the application path index.
+        :return: The contents of ``briefcase.toml``
         """
         try:
-            with (self.bundle_path(app) / "briefcase.toml").open("rb") as f:
-                self._path_index[app] = tomllib.load(f)["paths"]
-        except OSError as e:
-            raise BriefcaseCommandError(
-                f"Unable to find '{self.bundle_path(app) / 'briefcase.toml'}'"
-            ) from e
-        return self._path_index[app]
+            return self._briefcase_toml[app]
+        except KeyError:
+            try:
+                with (self.bundle_path(app) / "briefcase.toml").open("rb") as f:
+                    self._briefcase_toml[app] = tomllib.load(f)
+            except OSError as e:
+                raise MissingAppMetadata(self.bundle_path(app)) from e
+            else:
+                return self._briefcase_toml[app]
 
-    def support_path(self, app: BaseConfig):
-        """Obtain the path into which the support package should be unpacked.
+    def path_index(self, app: AppConfig, path_name: str) -> str | dict | list:
+        """Return a path from the path index provided by the app template.
+
+        Raises KeyError if ``path_name`` is not defined in the index.
 
         :param app: The config object for the app
-        :return: The full path where the support package should be unpacked.
+        :param path_name: Name of the filepath to retrieve
+        :return: filepath for requested path
+        """
+        return self.briefcase_toml(app)["paths"][path_name]
+
+    def template_target_version(self, app: AppConfig) -> str | None:
+        """The target version of Briefcase for the app from ``briefcase.toml``.
+
+        This value represents a version epoch specific to the platform. An epoch
+        begins when a breaking change is introduced. Therefore, this value would
+        remain the version of Briefcase that introduced a breaking change for a
+        template until another such change requires a new epoch.
+
+        :param app: The config object for the app
+        :return: target version or None if one isn't specified
         """
-        # If the index file hasn't been loaded for this app, load it.
         try:
-            path_index = self._path_index[app]
+            return self.briefcase_toml(app)["briefcase"]["target_version"]
         except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["support_path"]
+            return None
+
+    def support_path(self, app: AppConfig) -> Path:
+        """Obtain the path into which the support package should be unpacked.
+
+        :param app: The config object for the app
+        :return: The full path where the support package should be unpacked.
+        """
+        return self.bundle_path(app) / self.path_index(app, "support_path")
 
-    def support_revision(self, app: BaseConfig):
+    def support_revision(self, app: AppConfig) -> str:
         """Obtain the support package revision that the template requires.
 
         :param app: The config object for the app
         :return: The support revision required by the template.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return path_index["support_revision"]
+        return self.path_index(app, "support_revision")
 
-    def cleanup_paths(self, app: BaseConfig):
+    def cleanup_paths(self, app: AppConfig) -> list[str]:
         """Obtain the paths generated by the app template that should be cleaned up
         prior to release.
 
@@ -375,53 +403,33 @@ a custom location for Briefcase's tools.
         :return: The list of path globs inside the app template that should
             be cleaned up.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return path_index["cleanup_paths"]
+        return self.path_index(app, "cleanup_paths")
 
-    def app_requirements_path(self, app: BaseConfig):
+    def app_requirements_path(self, app: AppConfig) -> Path:
         """Obtain the path into which a requirements.txt file should be written.
 
         :param app: The config object for the app
         :return: The full path where the requirements.txt file should be written
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["app_requirements_path"]
+        return self.bundle_path(app) / self.path_index(app, "app_requirements_path")
 
-    def app_packages_path(self, app: BaseConfig):
+    def app_packages_path(self, app: AppConfig) -> Path:
         """Obtain the path into which requirements should be installed.
 
         :param app: The config object for the app
         :return: The full path where application requirements should be installed.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["app_packages_path"]
+        return self.bundle_path(app) / self.path_index(app, "app_packages_path")
 
-    def app_path(self, app: BaseConfig):
+    def app_path(self, app: AppConfig) -> Path:
         """Obtain the path into which the application should be installed.
 
         :param app: The config object for the app
         :return: The full path where application code should be installed.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["app_path"]
+        return self.bundle_path(app) / self.path_index(app, "app_path")
 
-    def app_module_path(self, app):
+    def app_module_path(self, app: AppConfig) -> Path:
         """Find the path for the application module for an app.
 
         :param app: The config object for the app
@@ -482,9 +490,8 @@ a custom location for Briefcase's tools.
 
         Raises MissingToolException if a required system tool is missing.
         """
-        pass
 
-    def finalize_app_config(self, app: BaseConfig):
+    def finalize_app_config(self, app: AppConfig):
         """Finalize the application config.
 
         Some app configurations (notably, Linux system packages like .deb) have
@@ -500,9 +507,8 @@ a custom location for Briefcase's tools.
 
         :param app: The app configuration to finalize.
         """
-        pass
 
-    def finalize(self, app: Optional[BaseConfig] = None):
+    def finalize(self, app: AppConfig | None = None):
         """Finalize Briefcase configuration.
 
         This will:
@@ -529,9 +535,51 @@ a custom location for Briefcase's tools.
                 self.finalize_app_config(app)
                 delattr(app, "__draft__")
 
-    def verify_app_tools(self, app: BaseConfig):
+    def verify_app(self, app: AppConfig):
+        """Verify the app is compatible and the app tools are available.
+
+        This is the last step of verification for a Command before running the
+        Command's business logic. It runs _after_ pre-requisite Commands have been
+        verified and run.
+
+        :param app: app configuration
+        """
+        self.verify_app_template(app)
+        self.verify_app_tools(app)
+
+    def verify_app_tools(self, app: AppConfig):
         """Verify that tools needed to run the command for this app exist."""
-        pass
+
+    def verify_app_template(self, app: AppConfig):
+        """Verify the template targets the same Briefcase version as the Command.
+
+        :param app: app configuration
+        """
+
+        # Skip this check if the template isn't rolled out
+        # or if the command doesn't support templates
+        try:
+            template_target_version = self.template_target_version(app)
+        except (MissingAppMetadata, NotImplementedError):
+            return
+
+        if self.platform_target_version != template_target_version:
+            raise BriefcaseCommandError(
+                f"""\
+The app template used to generate this app is not compatible with this version
+of Briefcase.
+
+If the app was generated with an earlier version of Briefcase using the default
+Briefcase template, you can run:
+
+     $ briefcase create {self.platform} {self.output_format}
+
+to re-generate your app with a compatible version of the template.
+
+If you are using a custom template, you'll need to update the template to correct
+any compatibility problems, and then add the compatibility declaration.
+"""
+            )
 
     def parse_options(self, extra):
         """Parse the command line arguments for the Command.
@@ -611,7 +659,6 @@ a custom location for Briefcase's tools.
 
         :param command: The command whose options are to be cloned
         """
-        pass
 
     def add_default_options(self, parser):
         """Add the default options that exist on *all* commands.
@@ -711,7 +758,6 @@ a custom location for Briefcase's tools.
 
         :param parser: a stub argparse parser for the command.
         """
-        pass
 
     def parse_config(self, filename):
         try:
@@ -726,7 +772,7 @@ a custom location for Briefcase's tools.
                 )
 
                 self.global_config = create_config(
-                    klass=self.GLOBAL_CONFIG_CLASS,
+                    klass=GlobalConfig,
                     config=global_config,
                     msg="Global configuration",
                 )
@@ -735,7 +781,7 @@ a custom location for Briefcase's tools.
                     # Construct an AppConfig object with the final set of
                     # configuration options for the app.
                     self.apps[app_name] = create_config(
-                        klass=self.APP_CONFIG_CLASS,
+                        klass=AppConfig,
                         config=app_config,
                         msg=f"Configuration for '{app_name}'",
                     )
@@ -771,12 +817,13 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
             # try to update it using git. If no cache exists, or if the cache
             # directory isn't a git directory, or git fails for some reason,
             # fall back to using the specified template directly.
+            cached_template = cookiecutter_cache_path(template)
             try:
-                cached_template = cookiecutter_cache_path(template)
                 repo = self.tools.git.Repo(cached_template)
+                # Raises ValueError if "origin" isn't a valid remote
+                remote = repo.remote(name="origin")
                 try:
                     # Attempt to update the repository
-                    remote = repo.remote(name="origin")
                     remote.fetch()
                 except self.tools.git.exc.GitCommandError as e:
                     # We are offline, or otherwise unable to contact
diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py
index 6c5b85f0..3c74a96e 100644
--- a/src/briefcase/commands/build.py
+++ b/src/briefcase/commands/build.py
@@ -1,6 +1,6 @@
-from typing import Optional
+from __future__ import annotations
 
-from briefcase.config import BaseConfig
+from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError
 
 from .base import BaseCommand, full_options
@@ -14,7 +14,7 @@ class BuildCommand(BaseCommand):
         self._add_update_options(parser, context_label=" before building")
         self._add_test_options(parser, context_label="Build")
 
-    def build_app(self, app: BaseConfig, **options):
+    def build_app(self, app: AppConfig, **options):
         """Build an application.
 
         :param app: The application to build
@@ -23,7 +23,7 @@ class BuildCommand(BaseCommand):
 
     def _build_app(
         self,
-        app: BaseConfig,
+        app: AppConfig,
         update: bool,
         update_requirements: bool,
         update_resources: bool,
@@ -31,7 +31,7 @@ class BuildCommand(BaseCommand):
         no_update: bool,
         test_mode: bool,
         **options,
-    ):
+    ) -> dict | None:
         """Internal method to invoke a build on a single app. Ensures the app exists,
         and has been updated (if requested) before attempting to issue the actual build
         command.
@@ -68,7 +68,7 @@ class BuildCommand(BaseCommand):
         else:
             state = None
 
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         state = self.build_app(app, test_mode=test_mode, **full_options(state, options))
 
@@ -81,7 +81,7 @@ class BuildCommand(BaseCommand):
 
     def __call__(
         self,
-        app: Optional[BaseConfig] = None,
+        app: AppConfig | None = None,
         update: bool = False,
         update_requirements: bool = False,
         update_resources: bool = False,
@@ -89,7 +89,7 @@ class BuildCommand(BaseCommand):
         no_update: bool = False,
         test_mode: bool = False,
         **options,
-    ):
+    ) -> dict | None:
         # Has the user requested an invalid set of options?
         # This can't be done with argparse, because it isn't a simple mutually exclusive group.
         if no_update:
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 40e30cc7..482b00b4 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import hashlib
 import os
 import platform
@@ -6,12 +8,11 @@ import subprocess
 import sys
 from datetime import date
 from pathlib import Path
-from typing import List, Optional
 
 from packaging.version import Version
 
 import briefcase
-from briefcase.config import BaseConfig
+from briefcase.config import AppConfig
 from briefcase.exceptions import (
     BriefcaseCommandError,
     InvalidSupportPackage,
@@ -43,7 +44,7 @@ def cookiecutter_cache_path(template):
     return Path.home() / ".cookiecutters" / cache_name
 
 
-def write_dist_info(app: BaseConfig, dist_info_path: Path):
+def write_dist_info(app: AppConfig, dist_info_path: Path):
     """Install the dist-info folder for the application.
 
     :param app: The config object for the app
@@ -83,10 +84,6 @@ class CreateCommand(BaseCommand):
     command = "create"
     description = "Create a new app for a target platform."
 
-    def __init__(self, *args, **options):
-        super().__init__(*args, **options)
-        self._s3 = None
-
     @property
     def app_template_url(self):
         """The URL for a cookiecutter repository to use when creating apps."""
@@ -103,25 +100,19 @@ class CreateCommand(BaseCommand):
             + self.support_package_filename(support_revision)
         )
 
-    def icon_targets(self, app: BaseConfig):
+    def icon_targets(self, app: AppConfig):
         """Obtain the dictionary of icon targets that the template requires.
 
         :param app: The config object for the app
         :return: A dictionary of icons that the template supports. The keys
             of the dictionary are the size of the icons.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-
         # If the template specifies no icons, return an empty dictionary.
         # If the template specifies a single icon without a size specification,
         #   return a dictionary with a single ``None`` key.
         # Otherwise, return the full size-keyed dictionary.
         try:
-            icon_targets = path_index["icon"]
+            icon_targets = self.path_index(app, "icon")
             # Convert string-specified icons into an "unknown size" icon form
             if isinstance(icon_targets, str):
                 icon_targets = {None: icon_targets}
@@ -130,25 +121,19 @@ class CreateCommand(BaseCommand):
 
         return icon_targets
 
-    def splash_image_targets(self, app: BaseConfig):
+    def splash_image_targets(self, app: AppConfig):
         """Obtain the dictionary of splash image targets that the template requires.
 
         :param app: The config object for the app
         :return: A dictionary of splash images that the template supports. The keys
             of the dictionary are the size of the splash images.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-
         # If the template specifies no splash images, return an empty dictionary.
         # If the template specifies a single splash image without a size specification,
         #   return a dictionary with a single ``None`` key.
         # Otherwise, return the full size-keyed dictionary.
         try:
-            splash_targets = path_index["splash"]
+            splash_targets = self.path_index(app, "splash")
             # Convert string-specified splash images into an "unknown size" icon form
             if isinstance(splash_targets, str):
                 splash_targets = {None: splash_targets}
@@ -157,7 +142,7 @@ class CreateCommand(BaseCommand):
 
         return splash_targets
 
-    def document_type_icon_targets(self, app: BaseConfig):
+    def document_type_icon_targets(self, app: AppConfig):
         """Obtain the dictionary of document type icon targets that the template
         requires.
 
@@ -167,33 +152,28 @@ class CreateCommand(BaseCommand):
             describes the path fragments (relative to the bundle path) for the images
             that are required; the keys are the size of the splash images.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-
         # If the template specifies no document types, return an empty dictionary.
         # Then, for each document type; If the template specifies a single icon
         #   without a size specification, return a dictionary with a single
         #   ``None`` key. Otherwise, return the full size-keyed dictionary.
         try:
+            icon_targets = self.path_index(app, "document_type_icon")
             return {
                 extension: {None: targets} if isinstance(targets, str) else targets
-                for extension, targets in path_index["document_type_icon"].items()
+                for extension, targets in icon_targets.items()
             }
 
         except KeyError:
             return {}
 
-    def output_format_template_context(self, app: BaseConfig):
+    def output_format_template_context(self, app: AppConfig):
         """Additional template context required by the output format.
 
         :param app: The config object for the app
         """
         return {}
 
-    def generate_app_template(self, app: BaseConfig):
+    def generate_app_template(self, app: AppConfig):
         """Create an application bundle.
 
         :param app: The config object for the app
@@ -304,7 +284,7 @@ class CreateCommand(BaseCommand):
         with self.input.wait_bar("Removing existing support package..."):
             self.tools.shutil.rmtree(support_path)
 
-    def cleanup_app_support_package(self, app: BaseConfig):
+    def cleanup_app_support_package(self, app: AppConfig):
         """Clean up an existing application support package.
 
         :param app: The config object for the app
@@ -318,7 +298,7 @@ class CreateCommand(BaseCommand):
             if support_path.exists():
                 self._cleanup_app_support_package(support_path)
 
-    def install_app_support_package(self, app: BaseConfig):
+    def install_app_support_package(self, app: AppConfig):
         """Install the application support package.
 
         :param app: The config object for the app
@@ -331,7 +311,7 @@ class CreateCommand(BaseCommand):
             support_file_path = self._download_support_package(app)
             self._unpack_support_package(support_file_path, support_path)
 
-    def _download_support_package(self, app):
+    def _download_support_package(self, app: AppConfig):
         try:
             # Work out if the app defines a custom override for
             # the support package URL.
@@ -408,8 +388,8 @@ class CreateCommand(BaseCommand):
 
     def _write_requirements_file(
         self,
-        app: BaseConfig,
-        requires: List[str],
+        app: AppConfig,
+        requires: list[str],
         requirements_path: Path,
     ):
         """Configure application requirements by writing a requirements.txt file.
@@ -433,7 +413,7 @@ class CreateCommand(BaseCommand):
                             requirement = os.path.abspath(self.base_path / requirement)
                         f.write(f"{requirement}\n")
 
-    def _pip_requires(self, app: BaseConfig, requires: List[str]):
+    def _pip_requires(self, app: AppConfig, requires: list[str]):
         """Convert the list of requirements to be passed to pip into its final form.
 
         :param app: The app configuration
@@ -442,7 +422,7 @@ class CreateCommand(BaseCommand):
         """
         return requires
 
-    def _extra_pip_args(self, app: BaseConfig):
+    def _extra_pip_args(self, app: AppConfig):
         """Any additional arguments that must be passed to pip when installing packages.
 
         :param app: The app configuration
@@ -450,7 +430,7 @@ class CreateCommand(BaseCommand):
         """
         return []
 
-    def _pip_kwargs(self, app: BaseConfig):
+    def _pip_kwargs(self, app: AppConfig):
         """Generate the kwargs to pass when invoking pip.
 
         :param app: The app configuration
@@ -472,8 +452,8 @@ class CreateCommand(BaseCommand):
 
     def _install_app_requirements(
         self,
-        app: BaseConfig,
-        requires: List[str],
+        app: AppConfig,
+        requires: list[str],
         app_packages_path: Path,
     ):
         """Install requirements for the app with pip.
@@ -513,7 +493,7 @@ class CreateCommand(BaseCommand):
         else:
             self.logger.info("No application requirements.")
 
-    def install_app_requirements(self, app: BaseConfig, test_mode: bool):
+    def install_app_requirements(self, app: AppConfig, test_mode: bool):
         """Handle requirements for the app.
 
         This will result in either (in preferential order):
@@ -547,7 +527,7 @@ class CreateCommand(BaseCommand):
                     "`app_requirements_path` or `app_packages_path`"
                 ) from e
 
-    def install_app_code(self, app: BaseConfig, test_mode: bool):
+    def install_app_code(self, app: AppConfig, test_mode: bool):
         """Install the application code into the bundle.
 
         :param app: The config object for the app
@@ -659,7 +639,7 @@ class CreateCommand(BaseCommand):
                     f"Unable to find {source_filename} for {full_role}; using default"
                 )
 
-    def install_app_resources(self, app: BaseConfig):
+    def install_app_resources(self, app: AppConfig):
         """Install the application resources (such as icons and splash screens) into the
         bundle.
 
@@ -719,7 +699,7 @@ class CreateCommand(BaseCommand):
                     target=self.bundle_path(app) / target,
                 )
 
-    def cleanup_app_content(self, app: BaseConfig):
+    def cleanup_app_content(self, app: AppConfig):
         """Remove any content not needed by the final app bundle.
 
         :param app: The config object for the app
@@ -755,7 +735,7 @@ class CreateCommand(BaseCommand):
                         self.logger.info(f"Removing {relative_path}")
                         path.unlink()
 
-    def create_app(self, app: BaseConfig, test_mode: bool = False, **options):
+    def create_app(self, app: AppConfig, test_mode: bool = False, **options):
         """Create an application bundle.
 
         :param app: The config object for the app
@@ -784,9 +764,9 @@ class CreateCommand(BaseCommand):
         self.logger.info("Installing support package...", prefix=app.app_name)
         self.install_app_support_package(app=app)
 
-        # Verify tools for the app after the app template and support package
+        # Verify the app after the app template and support package
         # are in place since the app tools may be dependent on them.
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         self.logger.info("Installing application code...", prefix=app.app_name)
         self.install_app_code(app=app, test_mode=test_mode)
@@ -813,12 +793,12 @@ class CreateCommand(BaseCommand):
         super().verify_tools()
         Git.verify(tools=self.tools)
 
-    def verify_app_tools(self, app: BaseConfig):
+    def verify_app_tools(self, app: AppConfig):
         """Verify that tools needed to run the command for this app exist."""
         super().verify_app_tools(app)
         NativeAppContext.verify(tools=self.tools, app=app)
 
-    def __call__(self, app: Optional[BaseConfig] = None, **options):
+    def __call__(self, app: AppConfig | None = None, **options) -> dict | None:
         # Confirm host compatibility, that all required tools are available,
         # and that the app configuration is finalized.
         self.finalize(app)
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index 2ea947c9..1ad27576 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -5,7 +5,7 @@ from pathlib import Path
 from typing import List, Optional
 
 from briefcase.commands.run import RunAppMixin
-from briefcase.config import BaseConfig
+from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError, RequirementsInstallError
 
 from .base import BaseCommand
@@ -57,7 +57,7 @@ class DevCommand(RunAppMixin, BaseCommand):
             help="Run the app in test mode",
         )
 
-    def install_dev_requirements(self, app: BaseConfig, **options):
+    def install_dev_requirements(self, app: AppConfig, **options):
         """Install the requirements for the app dev.
 
         This will always include test requirements, if specified.
@@ -90,7 +90,7 @@ class DevCommand(RunAppMixin, BaseCommand):
 
     def run_dev_app(
         self,
-        app: BaseConfig,
+        app: AppConfig,
         env: dict,
         test_mode: bool,
         passthrough: List[str],
@@ -182,7 +182,7 @@ class DevCommand(RunAppMixin, BaseCommand):
         # and that the app configuration is finalized.
         self.finalize(app)
 
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         # Look for the existence of a dist-info file.
         # If one exists, assume that the requirements have already been
diff --git a/src/briefcase/commands/open.py b/src/briefcase/commands/open.py
index e63397a4..ec8f3890 100644
--- a/src/briefcase/commands/open.py
+++ b/src/briefcase/commands/open.py
@@ -1,6 +1,8 @@
-from typing import Optional
+from __future__ import annotations
 
-from briefcase.config import BaseConfig
+from abc import abstractmethod
+
+from briefcase.config import AppConfig
 
 from .base import BaseCommand, full_options
 
@@ -9,7 +11,11 @@ class OpenCommand(BaseCommand):
     command = "open"
     description = "Open an app in the build tool for the target platform."
 
-    def _open_app(self, app: BaseConfig):
+    @abstractmethod
+    def project_path(self, app: AppConfig):
+        """The path in to the project to pass to the shell to open the project."""
+
+    def _open_app(self, app: AppConfig):
         if self.tools.host_os == "Windows":  # pragma: no-cover-if-not-windows
             self.tools.os.startfile(self.project_path(app))
         elif self.tools.host_os == "Darwin":  # pragma: no-cover-if-not-macos
@@ -17,7 +23,7 @@ class OpenCommand(BaseCommand):
         else:  # pragma: no-cover-if-not-linux
             self.tools.subprocess.Popen(["xdg-open", self.project_path(app)])
 
-    def open_app(self, app: BaseConfig, **options):
+    def open_app(self, app: AppConfig, **options):
         """Open the project for an app.
 
         :param app: The application to open
@@ -28,7 +34,7 @@ class OpenCommand(BaseCommand):
         else:
             state = None
 
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         self.logger.info(
             f"Opening {self.project_path(app).relative_to(self.base_path)}...",
@@ -38,7 +44,7 @@ class OpenCommand(BaseCommand):
 
         return state
 
-    def __call__(self, app: Optional[BaseConfig] = None, **options):
+    def __call__(self, app: AppConfig | None = None, **options):
         # Confirm host compatibility, that all required tools are available,
         # and that the app configuration is finalized.
         self.finalize(app)
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 807290ba..552f429c 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -1,7 +1,8 @@
+from __future__ import annotations
+
 from abc import abstractmethod
-from typing import Optional
 
-from briefcase.config import BaseConfig
+from briefcase.config import AppConfig
 
 from .base import BaseCommand, full_options
 
@@ -35,9 +36,8 @@ class PackageCommand(BaseCommand):
 
         :param app: The app config
         """
-        ...
 
-    def package_app(self, app: BaseConfig, **options):
+    def package_app(self, app: AppConfig, **options):
         """Package an application.
 
         :param app: The application to package
@@ -46,11 +46,11 @@ class PackageCommand(BaseCommand):
 
     def _package_app(
         self,
-        app: BaseConfig,
+        app: AppConfig,
         update: bool,
         packaging_format: str,
         **options,
-    ):
+    ) -> dict | None:
         """Internal method to invoke packaging on a single app. Ensures the app exists,
         and has been updated (if requested) before attempting to issue the actual
         package command.
@@ -85,9 +85,9 @@ class PackageCommand(BaseCommand):
         # Annotate the packaging format onto the app
         app.packaging_format = packaging_format
 
-        # Verify the app tools, which will do final confirmation that we can
+        # Verify the app, which will do final confirmation that we can
         # package in the requested format.
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         # If the distribution artefact already exists, remove it.
         if self.distribution_path(app).exists():
@@ -135,8 +135,11 @@ class PackageCommand(BaseCommand):
         )
 
     def __call__(
-        self, app: Optional[BaseConfig] = None, update: bool = False, **options
-    ):
+        self,
+        app: AppConfig | 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)
diff --git a/src/briefcase/commands/publish.py b/src/briefcase/commands/publish.py
index 0c5d5b2a..689089f8 100644
--- a/src/briefcase/commands/publish.py
+++ b/src/briefcase/commands/publish.py
@@ -1,4 +1,6 @@
-from briefcase.config import BaseConfig
+from __future__ import annotations
+
+from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError
 
 from .base import BaseCommand, full_options
@@ -28,7 +30,7 @@ class PublishCommand(BaseCommand):
             help="The channel to publish to",
         )
 
-    def publish_app(self, app: BaseConfig, channel: str, **options):
+    def publish_app(self, app: AppConfig, channel: str, **options):
         """Publish an application.
 
         :param app: The application to publish
@@ -38,7 +40,7 @@ class PublishCommand(BaseCommand):
             f"TODO: Publish {app.app_name} to {channel}"
         )  # pragma: no cover
 
-    def _publish_app(self, app: BaseConfig, channel: str, **options):
+    def _publish_app(self, app: AppConfig, channel: str, **options) -> dict | None:
         """Internal method to publish a single app. Ensures the app exists, and has been
         packaged before attempting to issue the actual publish command.
 
@@ -47,7 +49,7 @@ class PublishCommand(BaseCommand):
         """
         # TODO: Verify the app has been packaged
         state = None
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         state = self.publish_app(app, channel=channel, **full_options(state, options))
 
diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py
index 93bb77d1..0c4a1a50 100644
--- a/src/briefcase/commands/run.py
+++ b/src/briefcase/commands/run.py
@@ -1,8 +1,9 @@
+from __future__ import annotations
+
 import re
 from abc import abstractmethod
-from typing import List, Optional
 
-from briefcase.config import BaseConfig
+from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError, BriefcaseTestSuiteFailure
 from briefcase.integrations.subprocess import StopStreaming
 
@@ -119,7 +120,7 @@ class RunAppMixin:
 
     def _stream_app_logs(
         self,
-        app: BaseConfig,
+        app: AppConfig,
         popen,
         test_mode=False,
         clean_filter=None,
@@ -209,7 +210,7 @@ class RunCommand(RunAppMixin, BaseCommand):
         self._add_update_options(parser, context_label=" before running")
         self._add_test_options(parser, context_label="Run")
 
-    def _prepare_app_env(self, app: BaseConfig, test_mode: bool):
+    def _prepare_app_env(self, app: AppConfig, test_mode: bool):
         """Prepare the environment for running an app as a log stream.
 
         This won't be used by every backend; but it's a sufficiently common
@@ -233,25 +234,24 @@ class RunCommand(RunAppMixin, BaseCommand):
             return {}
 
     @abstractmethod
-    def run_app(self, app: BaseConfig, **options):
+    def run_app(self, app: AppConfig, **options) -> dict | None:
         """Start an application.
 
         :param app: The application to start
         """
-        ...
 
     def __call__(
         self,
-        appname: Optional[str] = None,
+        appname: str | None = None,
         update: bool = False,
         update_requirements: bool = False,
         update_resources: bool = False,
         update_support: bool = False,
         no_update: bool = False,
         test_mode: bool = False,
-        passthrough: Optional[List[str]] = None,
+        passthrough: list[str] | None = None,
         **options,
-    ):
+    ) -> dict | None:
         # Which app should we run? If there's only one defined
         # in pyproject.toml, then we can use it as a default;
         # otherwise look for a -a/--app option.
@@ -299,7 +299,7 @@ class RunCommand(RunAppMixin, BaseCommand):
         else:
             state = None
 
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         state = self.run_app(
             app,
diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py
index 965b062c..4b3809fc 100644
--- a/src/briefcase/commands/update.py
+++ b/src/briefcase/commands/update.py
@@ -1,6 +1,6 @@
-from typing import Optional
+from __future__ import annotations
 
-from briefcase.config import BaseConfig
+from briefcase.config import AppConfig
 
 from .base import full_options
 from .create import CreateCommand
@@ -11,21 +11,18 @@ class UpdateCommand(CreateCommand):
     description = "Update the source, dependencies, and resources for an app."
 
     def add_options(self, parser):
-        self._add_update_options(
-            parser,
-            update=False,
-        )
+        self._add_update_options(parser, update=False)
         self._add_test_options(parser, context_label="Update")
 
     def update_app(
         self,
-        app: BaseConfig,
+        app: AppConfig,
         update_requirements: bool,
         update_resources: bool,
         update_support: bool,
         test_mode: bool,
         **options,
-    ):
+    ) -> dict | None:
         """Update an existing application bundle.
 
         :param app: The config object for the app
@@ -35,14 +32,13 @@ class UpdateCommand(CreateCommand):
         :param test_mode: Should the app be updated in test mode?
         """
 
-        bundle_path = self.bundle_path(app)
-        if not bundle_path.exists():
+        if not self.bundle_path(app).exists():
             self.logger.error(
                 "Application does not exist; call create first!", prefix=app.app_name
             )
             return
 
-        self.verify_app_tools(app)
+        self.verify_app(app)
 
         self.logger.info("Updating application code...", prefix=app.app_name)
         self.install_app_code(app=app, test_mode=test_mode)
@@ -67,13 +63,13 @@ class UpdateCommand(CreateCommand):
 
     def __call__(
         self,
-        app: Optional[BaseConfig] = None,
+        app: AppConfig | None = None,
         update_requirements: bool = False,
         update_resources: bool = False,
         update_support: bool = False,
         test_mode: bool = False,
         **options,
-    ):
+    ) -> dict | None:
         # Confirm host compatibility, that all required tools are available,
         # and that the app configuration is finalized.
         self.finalize(app)
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index e36f120e..005f7809 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -147,6 +147,11 @@ class InvalidSupportPackage(BriefcaseCommandError):
         super().__init__(f"Unable to unpack support package {filename!r}")
 
 
+class MissingAppMetadata(BriefcaseCommandError):
+    def __init__(self, app_bundle_path):
+        super().__init__(f"Unable to find '{app_bundle_path / 'briefcase.toml'}'")
+
+
 class MissingSupportPackage(BriefcaseCommandError):
     def __init__(self, python_version_tag, platform, host_arch):
         self.python_version_tag = python_version_tag
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 1da9f35e..5a465d5f 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -13,18 +13,27 @@ class JDK(ManagedTool):
     name = "java"
     full_name = "Java JDK"
 
+    # As of 12 May 2023, 17.0.7+7 is the current OpenJDK
+    # https://adoptium.net/temurin/releases/
+    JDK_MAJOR_VER = "17"
+    JDK_RELEASE = "17.0.7"
+    JDK_BUILD = "7"
+    JDK_INSTALL_DIR_NAME = f"java{JDK_MAJOR_VER}"
+
     def __init__(self, tools: ToolCache, java_home: Path):
         super().__init__(tools=tools)
-
-        # As of April 10 2020, 8u242-b08 is the current AdoptOpenJDK
-        # https://adoptopenjdk.net/releases.html
-        self.release = "8u242"
-        self.build = "b08"
-
         self.java_home = java_home
 
     @property
-    def adoptOpenJDK_download_url(self) -> str:
+    def OpenJDK_download_url(self):
+        arch = {
+            "x86_64": "x64",  # Linux\macOS x86-64
+            "aarch64": "aarch64",  # Linux arm64
+            "armv6l": "arm",  # Linux arm
+            "arm64": "aarch64",  # macOS arm64
+            "AMD64": "x64",  # Windows x86-64
+        }.get(self.tools.host_arch)
+
         platform = {
             "Darwin": "mac",
             "Windows": "windows",
@@ -36,22 +45,23 @@ class JDK(ManagedTool):
         }.get(self.tools.host_os, "tar.gz")
 
         return (
-            "https://github.com/AdoptOpenJDK/openjdk8-binaries/"
-            f"releases/download/jdk{self.release}-{self.build}/"
-            f"OpenJDK8U-jdk_x64_{platform}_hotspot_{self.release}{self.build}.{extension}"
+            f"https://github.com/adoptium/temurin{self.JDK_MAJOR_VER}-binaries/"
+            f"releases/download/jdk-{self.JDK_RELEASE}+{self.JDK_BUILD}/"
+            f"OpenJDK{self.JDK_MAJOR_VER}U-jdk_{arch}_{platform}_hotspot_"
+            f"{self.JDK_RELEASE}_{self.JDK_BUILD}.{extension}"
         )
 
     @classmethod
     def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> JDK:
-        """Verify that a Java 8 JDK exists.
+        """Verify that a Java JDK exists.
 
         If ``JAVA_HOME`` is set, try that version. If it is a JRE, or its *not*
-        a Java 8 JDK, download one.
+        a Java JDK, download one.
 
         On macOS, also try invoking /usr/libexec/java_home. If that location
-        points to a Java 8 JDK, use it.
+        points to a Java JDK, use it.
 
-        Otherwise, download a JDK from AdoptOpenJDK and unpack it into the
+        Otherwise, download a JDK from OpenJDK and unpack it into the
         briefcase data directory.
 
         :param tools: ToolCache of available tools
@@ -67,10 +77,6 @@ class JDK(ManagedTool):
         java_home = tools.os.environ.get("JAVA_HOME", "")
         install_message = None
 
-        if tools.host_arch == "arm64" and tools.host_os == "Darwin":
-            # Java 8 is not available for macOS on ARM64, so we will require Rosetta.
-            cls.verify_rosetta(tools=tools)
-
         # macOS has a helpful system utility to determine JAVA_HOME. Try it.
         if not java_home and tools.host_os == "Darwin":
             try:
@@ -93,25 +99,24 @@ class JDK(ManagedTool):
                         "-version",
                     ],
                 )
-                # This should be a string of the form "javac 1.8.0_144\n"
+                # This should be a string of the form "javac 17.0.7\n"
                 version_str = output.strip("\n").split(" ")[1]
-                vparts = version_str.split(".")
-                if len(vparts) == 3 and vparts[:2] == ["1", "8"]:
-                    # It appears to be a Java 8 JDK.
-                    java = JDK(tools=tools, java_home=Path(java_home))
+                if version_str == cls.JDK_RELEASE:
+                    # It appears to be a Java JDK
+                    java = JDK(tools, java_home=Path(java_home))
                 else:
-                    # It's not a Java 8 JDK.
+                    # It's not a Java JDK.
                     install_message = f"""
 *************************************************************************
-** WARNING: JAVA_HOME does not point to a Java 8 JDK                   **
+** WARNING: JAVA_HOME does not point to a Java {cls.JDK_MAJOR_VER} JDK                  **
 *************************************************************************
 
-    Android requires a Java 8 JDK, but the location pointed to by the
+    Android requires a Java {cls.JDK_MAJOR_VER} JDK, but the location pointed to by the
     JAVA_HOME environment variable:
 
     {java_home}
 
-    isn't a Java 8 JDK (it appears to be Java {version_str}).
+    isn't a Java {cls.JDK_MAJOR_VER} JDK (it appears to be Java {version_str}).
 
     Briefcase will use its own JDK instance.
 
@@ -194,7 +199,7 @@ class JDK(ManagedTool):
         if java is None:
             # If we've reached this point, any user-provided JAVA_HOME is broken;
             # use the Briefcase one.
-            java_home = tools.base_path / "java"
+            java_home = tools.base_path / cls.JDK_INSTALL_DIR_NAME
 
             # The macOS download has a weird layout (inherited from the official Oracle
             # release). The actual JAVA_HOME is deeper inside the directory structure.
@@ -210,7 +215,7 @@ class JDK(ManagedTool):
                     if install_message:
                         tools.logger.warning(install_message)
                     tools.logger.info(
-                        "The Java JDK was not found; downloading and installing...",
+                        f"A Java {cls.JDK_MAJOR_VER} JDK was not found; downloading and installing...",
                         prefix=cls.name,
                     )
                     java.install()
@@ -237,12 +242,12 @@ class JDK(ManagedTool):
     def install(self):
         """Download and install a JDK."""
         jdk_zip_path = self.tools.download.file(
-            url=self.adoptOpenJDK_download_url,
+            url=self.OpenJDK_download_url,
             download_path=self.tools.base_path,
-            role="Java 8 JDK",
+            role=f"Java {self.JDK_MAJOR_VER} JDK",
         )
 
-        with self.tools.input.wait_bar("Installing AdoptOpenJDK..."):
+        with self.tools.input.wait_bar("Installing OpenJDK..."):
             try:
                 # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
                 self.tools.shutil.unpack_archive(
@@ -252,7 +257,7 @@ class JDK(ManagedTool):
             except (shutil.ReadError, EOFError) as e:
                 raise BriefcaseCommandError(
                     f"""\
-Unable to unpack AdoptOpenJDK ZIP file. The download may have been interrupted
+Unable to unpack OpenJDK ZIP file. The download may have been interrupted
 or corrupted.
 
 Delete {jdk_zip_path} and run briefcase again.
@@ -261,11 +266,13 @@ Delete {jdk_zip_path} and run briefcase again.
 
             jdk_zip_path.unlink()  # Zip file no longer needed once unpacked.
 
-            # The tarball will unpack into <briefcase data dir>/tools/jdk8u242-b08
+            # The tarball will unpack into <briefcase data dir>/tools/jdk-17.0.7+7
             # (or whatever name matches the current release).
             # We turn this into <briefcase data dir>/tools/java so we have a consistent name.
-            java_unpack_path = self.tools.base_path / f"jdk{self.release}-{self.build}"
-            java_unpack_path.rename(self.tools.base_path / "java")
+            java_unpack_path = (
+                self.tools.base_path / f"jdk-{self.JDK_RELEASE}+{self.JDK_BUILD}"
+            )
+            java_unpack_path.rename(self.tools.base_path / self.JDK_INSTALL_DIR_NAME)
 
     def uninstall(self):
         """Uninstall a JDK."""
@@ -274,22 +281,3 @@ Delete {jdk_zip_path} and run briefcase again.
                 self.tools.shutil.rmtree(self.java_home.parent.parent)
             else:
                 self.tools.shutil.rmtree(self.java_home)
-
-    @classmethod
-    def verify_rosetta(cls, tools: ToolCache):
-        try:
-            tools.subprocess.check_output(["arch", "-x86_64", "true"])
-        except subprocess.CalledProcessError:
-            tools.logger.info(
-                """\
-This command requires Rosetta, but it does not appear to be installed.
-Briefcase will attempt to install it now.
-"""
-            )
-            try:
-                tools.subprocess.run(
-                    ["softwareupdate", "--install-rosetta", "--agree-to-license"],
-                    check=True,
-                )
-            except subprocess.CalledProcessError as e:
-                raise BriefcaseCommandError("Failed to install Rosetta") from e
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 4cc00417..859f3a8b 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -60,6 +60,7 @@ def android_log_clean_filter(line):
 class GradleMixin:
     output_format = "gradle"
     platform = "android"
+    platform_target_version = "0.3.15"
 
     @property
     def packaging_formats(self):
@@ -174,12 +175,7 @@ class GradleBuildCommand(GradleMixin, BuildCommand):
     description = "Build an Android debug APK."
 
     def metadata_resource_path(self, app: BaseConfig):
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["metadata_resource_path"]
+        return self.bundle_path(app) / self.path_index(app, "metadata_resource_path")
 
     def update_app_metadata(self, app: BaseConfig, test_mode: bool):
         with self.input.wait_bar("Setting main module..."):
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index d38d1df6..676c787d 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -282,12 +282,7 @@ class iOSXcodeBuildCommand(iOSXcodePassiveMixin, BuildCommand):
         :param app: The config object for the app
         :return: The full path of the application's plist file.
         """
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["info_plist_path"]
+        return self.bundle_path(app) / self.path_index(app, "info_plist_path")
 
     def update_app_metadata(self, app: BaseConfig, test_mode: bool):
         with self.input.wait_bar("Setting main module..."):
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 0d4878f5..dbdc2e84 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -206,12 +206,7 @@ class macOSSigningMixin:
         self.get_identities = get_identities
 
     def entitlements_path(self, app: BaseConfig):
-        # If the index file hasn't been loaded for this app, load it.
-        try:
-            path_index = self._path_index[app]
-        except KeyError:
-            path_index = self._load_path_index(app)
-        return self.bundle_path(app) / path_index["entitlements_path"]
+        return self.bundle_path(app) / self.path_index(app, "entitlements_path")
 
     def select_identity(self, identity=None):
         """Get the codesigning identity to use.


Test patch:
diff --git a/tests/commands/base/conftest.py b/tests/commands/base/conftest.py
index de9f65e6..d1c2a258 100644
--- a/tests/commands/base/conftest.py
+++ b/tests/commands/base/conftest.py
@@ -1,7 +1,7 @@
 import pytest
 
 from briefcase.commands.base import BaseCommand
-from briefcase.config import AppConfig, BaseConfig
+from briefcase.config import AppConfig
 from briefcase.console import Console, Log
 
 
@@ -87,36 +87,7 @@ package = DummyPackageCommand
 publish = DummyPublishCommand
 
 
-# Define a command that defines a custom config class, and has no options.
-class CustomGlobalConfig(BaseConfig):
-    def __init__(self, foo, **kwargs):
-        super().__init__(**kwargs)
-        self.foo = foo
-
-    def __repr__(self):
-        return f"<Custom GlobalConfig {self.foo}>"
-
-
-class CustomAppConfig(AppConfig):
-    def __init__(self, foo, bar, **kwargs):
-        super().__init__(
-            app_name="custom",
-            bundle="com.example",
-            description="Custom app",
-            version="37.42",
-            sources=["src/custom"],
-        )
-        self.foo = foo
-        self.bar = bar
-
-    def __repr__(self):
-        return f"<Custom AppConfig {self.foo}, {self.bar}>"
-
-
 class OtherDummyCommand(BaseCommand):
-    GLOBAL_CONFIG_CLASS = CustomGlobalConfig
-    APP_CONFIG_CLASS = CustomAppConfig
-
     command = ("other",)
     platform = "tester"
     output_format = "dumdum"
diff --git a/tests/commands/base/test_parse_config.py b/tests/commands/base/test_parse_config.py
index 5821a28f..7f89c5a7 100644
--- a/tests/commands/base/test_parse_config.py
+++ b/tests/commands/base/test_parse_config.py
@@ -113,88 +113,3 @@ def test_parse_config(base_command):
     assert base_command.apps["secondapp"].bundle == "org.beeware"
     assert base_command.apps["secondapp"].mystery == "sekrits"
     assert base_command.apps["secondapp"].extra == "something"
-
-
-def test_parse_config_custom_config_classes_missing_global_arg(other_command):
-    """A command that defines custom config classes can enforce global arguments."""
-    filename = other_command.base_path / "base_path" / "pyproject.toml"
-    create_file(
-        filename,
-        """
-        [tool.briefcase]
-        version = "1.2.3"
-        description = "A sample app"
-        bundle = "org.beeware"
-        mystery = 'default'
-
-        [tool.briefcase.app.firstapp]
-        sources = ['src/firstapp']
-
-    """,
-    )
-
-    # Parse the configuration.
-    # Even though the global config has everything needed for the default
-    # configuration, it's missing a required option for the custom class.
-    with pytest.raises(
-        BriefcaseConfigError,
-        match=r"Global configuration is incomplete \(missing 'foo'\)",
-    ):
-        other_command.parse_config(filename)
-
-
-def test_parse_config_custom_config_classes_missing_app_arg(other_command):
-    """A command that defines custom config classes can enforce app arguments."""
-    filename = other_command.base_path / "base_path" / "pyproject.toml"
-    create_file(
-        filename,
-        """
-        [tool.briefcase]
-        foo = "spam"
-
-        [tool.briefcase.app.firstapp]
-
-    """,
-    )
-
-    # Parse the configuration.
-    # Even though the app config has everything needed for the default
-    # configuration, it's missing a required option for the custom class.
-    with pytest.raises(
-        BriefcaseConfigError,
-        match=r"Configuration for 'firstapp' is incomplete \(missing 'bar'\)",
-    ):
-        other_command.parse_config(filename)
-
-
-def test_parse_config_custom_config_classes(other_command):
-    """A well-formed configuration file can be augmented by the command line."""
-    filename = other_command.base_path / "base_path" / "pyproject.toml"
-    create_file(
-        filename,
-        """
-        [tool.briefcase]
-        foo = "spam"
-
-        [tool.briefcase.app.firstapp]
-        bar = "ham"
-    """,
-    )
-
-    # Parse the configuration.
-    other_command.parse_config(filename)
-
-    # There is a custom global configuration object
-    assert repr(other_command.global_config) == "<Custom GlobalConfig spam>"
-    assert other_command.global_config.foo == "spam"
-
-    # The app will have all the base attributes required by an app,
-    # defined in the config file.
-    assert repr(other_command.apps["firstapp"]) == "<Custom AppConfig spam, ham>"
-    assert other_command.apps["firstapp"].foo == "spam"
-    assert other_command.apps["firstapp"].bar == "ham"
-
-    # The custom class sets the underlying attributes of the AppConfig
-    assert other_command.apps["firstapp"].app_name == "custom"
-    assert other_command.apps["firstapp"].bundle == "com.example"
-    assert other_command.apps["firstapp"].version == "37.42"
diff --git a/tests/commands/base/test_verify_template.py b/tests/commands/base/test_verify_template.py
new file mode 100644
index 00000000..40abbc05
--- /dev/null
+++ b/tests/commands/base/test_verify_template.py
@@ -0,0 +1,43 @@
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+from ...utils import create_file
+
+
+def test_no_target_versions(base_command, my_app):
+    """Verification succeeds if no target versions are declared."""
+    base_command.platform_target_version = None
+
+    create_file(base_command.bundle_path(my_app) / "briefcase.toml", content="")
+
+    base_command.verify_app_template(my_app)
+
+
+def test_platform_version_compatible(base_command, my_app):
+    """Verification succeeds when template version matches platform version."""
+    base_command.platform_target_version = "42.42"
+
+    create_file(
+        base_command.bundle_path(my_app) / "briefcase.toml",
+        content="[briefcase]\ntarget_version = '42.42'",
+    )
+
+    base_command.verify_app_template(my_app)
+
+
+@pytest.mark.parametrize("template_version", ["", "32.32", "52.52"])
+def test_platform_version_incompatible(base_command, my_app, template_version):
+    """Verification fails when template version doesn't match platform version."""
+    base_command.platform_target_version = "42.42"
+
+    create_file(
+        base_command.bundle_path(my_app) / "briefcase.toml",
+        content=f"[briefcase]\ntarget_version = '{template_version}'",
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible with this version\nof Briefcase.",
+    ):
+        base_command.verify_app_template(my_app)
diff --git a/tests/commands/build/conftest.py b/tests/commands/build/conftest.py
index d1fcd667..99e40500 100644
--- a/tests/commands/build/conftest.py
+++ b/tests/commands/build/conftest.py
@@ -26,6 +26,10 @@ class DummyBuildCommand(BuildCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def binary_path(self, app):
         return self.bundle_path(app) / f"{app.app_name}.dummy.bin"
 
@@ -41,6 +45,10 @@ class DummyBuildCommand(BuildCommand):
         super().finalize_app_config(app=app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/build/test_call.py b/tests/commands/build/test_call.py
index 8cb92333..826afb25 100644
--- a/tests/commands/build/test_call.py
+++ b/tests/commands/build/test_call.py
@@ -25,6 +25,8 @@ def test_specific_app(build_command, first_app, second_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Build the first app; no state
@@ -55,10 +57,14 @@ def test_multiple_apps(build_command, first_app, second_app):
         # 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; no state
         ("build", "first", {"test_mode": False}),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # Build the second apps; state from previous build.
@@ -91,9 +97,13 @@ def test_non_existent(build_command, first_app_config, second_app):
         ("finalize-app-config", "second"),
         # First App doesn't exist, so it will be created, then built
         ("create", "first", {"test_mode": False}),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"create_state": "first", "test_mode": False}),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # Second app *does* exist, so it only be built
@@ -129,10 +139,14 @@ def test_unbuilt(build_command, first_app_unbuilt, second_app):
         # 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"),
         # First App exists, but hasn't been built; it will be built.
         ("build", "first", {"test_mode": False}),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # Second app has been built before; it will be built again.
@@ -174,6 +188,8 @@ def test_update_app(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": False}),
@@ -190,6 +206,8 @@ def test_update_app(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -234,6 +252,8 @@ def test_update_app_requirements(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": False}),
@@ -250,6 +270,8 @@ def test_update_app_requirements(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -294,6 +316,8 @@ def test_update_app_resources(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": False}),
@@ -310,6 +334,8 @@ def test_update_app_resources(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -345,6 +371,8 @@ def test_update_non_existent(build_command, first_app_config, second_app):
         ("finalize-app-config", "second"),
         # First App doesn't exist, so it will be created, then built
         ("create", "first", {"test_mode": False}),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"create_state": "first", "test_mode": False}),
@@ -361,6 +389,8 @@ def test_update_non_existent(build_command, first_app_config, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -410,6 +440,8 @@ def test_update_unbuilt(build_command, first_app_unbuilt, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": False}),
@@ -426,6 +458,8 @@ def test_update_unbuilt(build_command, first_app_unbuilt, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -470,6 +504,8 @@ def test_build_test(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": True}),
@@ -486,6 +522,8 @@ def test_build_test(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -521,10 +559,14 @@ def test_build_test_no_update(build_command, first_app, second_app):
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
         # No update of the first app
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"test_mode": True}),
         # No update of the second app
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -570,6 +612,8 @@ def test_build_test_update_dependencies(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": True}),
@@ -586,6 +630,8 @@ def test_build_test_update_dependencies(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -631,6 +677,8 @@ def test_build_test_update_resources(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"update_state": "first", "test_mode": True}),
@@ -647,6 +695,8 @@ def test_build_test_update_resources(build_command, first_app, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -761,6 +811,8 @@ def test_test_app_non_existent(build_command, first_app_config, second_app):
         ("finalize-app-config", "second"),
         # First App doesn't exist, so it will be created, then built
         ("create", "first", {"test_mode": True}),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         ("build", "first", {"create_state": "first", "test_mode": True}),
@@ -777,6 +829,8 @@ def test_test_app_non_existent(build_command, first_app_config, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
@@ -827,6 +881,8 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         (
@@ -847,6 +903,8 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app):
                 "update_support": False,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         (
diff --git a/tests/commands/create/conftest.py b/tests/commands/create/conftest.py
index 0971ca52..63ba7aef 100644
--- a/tests/commands/create/conftest.py
+++ b/tests/commands/create/conftest.py
@@ -27,8 +27,7 @@ class DefaultCreateCommand(CreateCommand):
     # An instance of CreateCommand that inherits the default
     # behavior of create handling.
 
-    # Two methods that are required by the interface, but are not needed
-    # for these tests.
+    # method is required by the interface, but are not needed for these tests.
     def binary_path(self, app):
         return NotImplementedError()
 
@@ -102,6 +101,10 @@ class TrackingCreateCommand(DummyCreateCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def verify_host(self):
         super().verify_host()
         self.actions.append(("verify-host",))
@@ -114,6 +117,10 @@ class TrackingCreateCommand(DummyCreateCommand):
         super().finalize_app_config(app=app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/create/test_call.py b/tests/commands/create/test_call.py
index 0ee8b994..15601399 100644
--- a/tests/commands/create/test_call.py
+++ b/tests/commands/create/test_call.py
@@ -36,6 +36,7 @@ def test_create(tracking_create_command, tmp_path):
         # Create the first app
         ("generate", "first"),
         ("support", "first"),
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("requirements", "first", False),
@@ -44,6 +45,7 @@ def test_create(tracking_create_command, tmp_path):
         # Create the second app
         ("generate", "second"),
         ("support", "second"),
+        ("verify-app-template", "second"),
         ("verify-app-tools", "second"),
         ("code", "second", False),
         ("requirements", "second", False),
@@ -75,6 +77,7 @@ def test_create_single(tracking_create_command, tmp_path):
         # Create the first app
         ("generate", "first"),
         ("support", "first"),
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("requirements", "first", False),
diff --git a/tests/commands/create/test_cleanup_app_content.py b/tests/commands/create/test_cleanup_app_content.py
index ddfcd758..635b5a41 100644
--- a/tests/commands/create/test_cleanup_app_content.py
+++ b/tests/commands/create/test_cleanup_app_content.py
@@ -130,8 +130,8 @@ def test_deep_glob_cleanup(create_command, myapp_unrolled, support_path):
 def test_template_glob_cleanup(create_command, myapp_unrolled, support_path):
     """A glob of files specified in the template will be added to the cleanup list."""
     # Define a cleanup_paths in the template *and* on the app
-    create_command._path_index = {
-        myapp_unrolled: {
+    create_command._briefcase_toml[myapp_unrolled] = {
+        "paths": {
             "cleanup_paths": ["path/to/support/dir1/a_*.*"],
         }
     }
diff --git a/tests/commands/create/test_create_app.py b/tests/commands/create/test_create_app.py
index d24a84cd..aa1e209b 100644
--- a/tests/commands/create/test_create_app.py
+++ b/tests/commands/create/test_create_app.py
@@ -15,6 +15,7 @@ def test_create_app(tracking_create_command, tmp_path):
     assert tracking_create_command.actions == [
         ("generate", "first"),
         ("support", "first"),
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("requirements", "first", False),
@@ -50,6 +51,7 @@ def test_create_existing_app_overwrite(tracking_create_command, tmp_path):
     assert tracking_create_command.actions == [
         ("generate", "first"),
         ("support", "first"),
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("requirements", "first", False),
diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py
index 5b721077..97cd61fd 100644
--- a/tests/commands/create/test_install_app_requirements.py
+++ b/tests/commands/create/test_install_app_requirements.py
@@ -158,7 +158,9 @@ def test_app_packages_valid_requires_no_support_package(
     myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"]
 
     # Override the cache of paths to specify an app packages path, but no support package path
-    create_command._path_index = {myapp: {"app_packages_path": "path/to/app_packages"}}
+    create_command._briefcase_toml[myapp] = {
+        "paths": {"app_packages_path": "path/to/app_packages"}
+    }
 
     create_command.install_app_requirements(myapp, test_mode=False)
 
diff --git a/tests/commands/create/test_install_app_resources.py b/tests/commands/create/test_install_app_resources.py
index 370cf7e0..9ef05e92 100644
--- a/tests/commands/create/test_install_app_resources.py
+++ b/tests/commands/create/test_install_app_resources.py
@@ -15,7 +15,7 @@ def test_no_resources(create_command):
     )
 
     # Prime the path index with no targets
-    create_command._path_index = {myapp: {}}
+    create_command._briefcase_toml[myapp] = {"paths": {}}
 
     install_image = mock.MagicMock()
     create_command.install_image = install_image
@@ -40,8 +40,8 @@ def test_icon_target(create_command, tmp_path):
     )
 
     # Prime the path index with 2 icon targets
-    create_command._path_index = {
-        myapp: {
+    create_command._briefcase_toml[myapp] = {
+        "paths": {
             "icon": {
                 "10": "path/to/icon-10.png",
                 "20": "path/to/icon-20.png",
@@ -109,8 +109,8 @@ def test_icon_variant_target(create_command, tmp_path):
     )
 
     # Prime the path index with 2 icon targets
-    create_command._path_index = {
-        myapp: {
+    create_command._briefcase_toml[myapp] = {
+        "paths": {
             "icon": {
                 "round": "path/to/round.png",
                 "square": {
@@ -193,8 +193,8 @@ def test_splash_target(create_command, tmp_path):
     )
 
     # Prime the path index with 2 splash targets
-    create_command._path_index = {
-        myapp: {
+    create_command._briefcase_toml[myapp] = {
+        "paths": {
             "splash": {
                 "10x20": "path/to/splash-10x20.png",
                 "20x30": "path/to/splash-20x30.png",
@@ -262,8 +262,8 @@ def test_splash_variant_target(create_command, tmp_path):
     )
 
     # Prime the path index with 2 splash targets
-    create_command._path_index = {
-        myapp: {
+    create_command._briefcase_toml[myapp] = {
+        "paths": {
             "splash": {
                 "portrait": "path/to/portrait.png",
                 "landscape": {
@@ -353,8 +353,8 @@ def test_doctype_icon_target(create_command, tmp_path):
     # Prime the path index with 2 document types;
     # * mydoc, which has a single static image; and
     # * other, which has multiple size targets.
-    create_command._path_index = {
-        myapp: {
+    create_command._briefcase_toml[myapp] = {
+        "paths": {
             "document_type_icon": {
                 "mydoc": "path/to/mydoc-icon.png",
                 "other": {
diff --git a/tests/commands/dev/test_call.py b/tests/commands/dev/test_call.py
index 748a13b2..075122a0 100644
--- a/tests/commands/dev/test_call.py
+++ b/tests/commands/dev/test_call.py
@@ -33,6 +33,10 @@ class DummyDevCommand(DevCommand):
         super().verify_tools()
         self.actions.append(("verify-tools",))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
@@ -72,6 +76,8 @@ def test_no_args_one_app(dev_command, first_app):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Run the first app devly
@@ -117,6 +123,8 @@ def test_with_arg_one_app(dev_command, first_app):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Run the first app devly
@@ -144,6 +152,8 @@ def test_with_arg_two_apps(dev_command, first_app, second_app):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "second"),
         # App tools are verified for app
         ("verify-app-tools", "second"),
         # Run the second app devly
@@ -190,6 +200,8 @@ def test_update_requirements(dev_command, first_app):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # An update was requested
@@ -218,6 +230,8 @@ def test_run_uninstalled(dev_command, first_app_uninstalled):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # The app will be installed
@@ -247,6 +261,8 @@ def test_update_uninstalled(dev_command, first_app_uninstalled):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # An update was requested
@@ -275,6 +291,8 @@ def test_no_run(dev_command, first_app_uninstalled):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Only update requirements without running the app
@@ -301,6 +319,8 @@ def test_run_test(dev_command, first_app):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -327,6 +347,8 @@ def test_run_test_uninstalled(dev_command, first_app_uninstalled):
         ("verify-host",),
         # Tools are verified
         ("verify-tools",),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Development requirements will be installed
diff --git a/tests/commands/open/conftest.py b/tests/commands/open/conftest.py
index 98ee5c65..4a8b2f0d 100644
--- a/tests/commands/open/conftest.py
+++ b/tests/commands/open/conftest.py
@@ -34,6 +34,10 @@ class DummyOpenCommand(OpenCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def binary_path(self, app):
         raise NotImplementedError(
             "Required by interface contract, but should not be used"
@@ -54,6 +58,10 @@ class DummyOpenCommand(OpenCommand):
         super().finalize_app_config(app=app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/open/test_call.py b/tests/commands/open/test_call.py
index de246a04..ce6f3c40 100644
--- a/tests/commands/open/test_call.py
+++ b/tests/commands/open/test_call.py
@@ -14,10 +14,15 @@ def test_open(open_command, first_app, second_app):
         # App configs have 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"),
         # open the first app
         ("open", "first"),
+        # App template is verified
+        ("verify-app-template", "second"),
+        # App tools are verified
         ("verify-app-tools", "second"),
         # open the second app
         ("open", "second"),
@@ -39,6 +44,8 @@ def test_open_single(open_command, first_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # open the first app
@@ -63,6 +70,8 @@ def test_create_before_open(open_command, tmp_path):
         ("finalize-app-config", "first"),
         # create, then open the first app
         ("create", "first", {}),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         ("open", "first"),
diff --git a/tests/commands/package/conftest.py b/tests/commands/package/conftest.py
index 07524ed9..30f969fc 100644
--- a/tests/commands/package/conftest.py
+++ b/tests/commands/package/conftest.py
@@ -54,6 +54,10 @@ class DummyPackageCommand(PackageCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def binary_path(self, app):
         return self.bundle_path(app) / f"{app.app_name}.bin"
 
@@ -72,6 +76,10 @@ class DummyPackageCommand(PackageCommand):
         super().finalize_app_config(app=app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/package/test_call.py b/tests/commands/package/test_call.py
index 21f7fd65..096236dc 100644
--- a/tests/commands/package/test_call.py
+++ b/tests/commands/package/test_call.py
@@ -22,6 +22,8 @@ def test_no_args_package_one_app(package_command, first_app, tmp_path):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -64,6 +66,8 @@ def test_package_one_explicit_app(package_command, first_app, second_app, tmp_pa
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -108,6 +112,8 @@ def test_no_args_package_two_app(package_command, first_app, second_app, tmp_pat
         # 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"),
         # Package the first app
@@ -119,6 +125,8 @@ def test_no_args_package_two_app(package_command, first_app, second_app, tmp_pat
                 "identity": None,
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # package the second app
@@ -164,6 +172,8 @@ def test_identity_arg_package_one_app(package_command, first_app, tmp_path):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -206,6 +216,8 @@ def test_adhoc_sign_package_one_app(package_command, first_app, tmp_path):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -254,6 +266,8 @@ def test_adhoc_sign_args_package_two_app(
         # App configs have been finalized
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -265,6 +279,8 @@ def test_adhoc_sign_args_package_two_app(
                 "identity": None,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # package the second app
@@ -314,6 +330,8 @@ def test_identity_sign_args_package_two_app(
         # App configs have been finalized
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -325,6 +343,8 @@ def test_identity_sign_args_package_two_app(
                 "identity": "test",
             },
         ),
+        # App template is verified
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # package the second app
@@ -369,6 +389,8 @@ def test_package_alternate_format(package_command, first_app, tmp_path):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -428,6 +450,8 @@ def test_create_before_package(package_command, first_app_config, tmp_path):
                 "identity": None,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -492,6 +516,8 @@ def test_update_package_one_app(package_command, first_app, tmp_path):
                 "update_state": "first",
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -558,6 +584,8 @@ def test_update_package_two_app(package_command, first_app, second_app, tmp_path
                 "update_state": "first",
             },
         ),
+        # App template is verified for first app
+        ("verify-app-template", "first"),
         # App tools are verified for first app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -599,6 +627,8 @@ def test_update_package_two_app(package_command, first_app, second_app, tmp_path
                 "package_state": "first",
             },
         ),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # package the second app
@@ -654,6 +684,8 @@ def test_build_before_package(package_command, first_app_unbuilt, tmp_path):
                 "identity": None,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -700,6 +732,8 @@ def test_already_packaged(package_command, first_app, tmp_path):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified for app
         ("verify-app-tools", "first"),
         # Package the first app
@@ -720,7 +754,7 @@ def test_already_packaged(package_command, first_app, tmp_path):
     assert tmp_path / "base_path" / "dist"
 
     # But the artefact has been deleted.
-    # NOTE: This is a testing quirk - beacuse we're mocking the
+    # NOTE: This is a testing quirk - because we're mocking the
     # 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()
diff --git a/tests/commands/publish/conftest.py b/tests/commands/publish/conftest.py
index ae97cbd3..f454c475 100644
--- a/tests/commands/publish/conftest.py
+++ b/tests/commands/publish/conftest.py
@@ -26,6 +26,10 @@ class DummyPublishCommand(PublishCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def binary_path(self, app):
         return self.bundle_path(app) / f"{app.app_name}.bin"
 
@@ -41,6 +45,10 @@ class DummyPublishCommand(PublishCommand):
         super().finalize_app_config(app=app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/publish/test_call.py b/tests/commands/publish/test_call.py
index d86f65df..59b27183 100644
--- a/tests/commands/publish/test_call.py
+++ b/tests/commands/publish/test_call.py
@@ -26,10 +26,14 @@ def test_publish(publish_command, first_app, second_app):
         # 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"),
         # Publish the first app to s3
         ("publish", "first", "s3", {}),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # Publish the second app to s3
@@ -60,10 +64,14 @@ def test_publish_alternative_channel(publish_command, first_app, second_app):
         # 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"),
         # Publish the first app to the alternative channel
         ("publish", "first", "alternative", {}),
+        # App template is verified for second app
+        ("verify-app-template", "second"),
         # App tools are verified for second app
         ("verify-app-tools", "second"),
         # Publish the second app to the alternative channel
diff --git a/tests/commands/run/conftest.py b/tests/commands/run/conftest.py
index 5e754e84..f7c196bc 100644
--- a/tests/commands/run/conftest.py
+++ b/tests/commands/run/conftest.py
@@ -26,6 +26,10 @@ class DummyRunCommand(RunCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def binary_path(self, app):
         return self.bundle_path(app) / f"{app.app_name}.bin"
 
@@ -41,6 +45,10 @@ class DummyRunCommand(RunCommand):
         super().finalize_app_config(app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/run/test_call.py b/tests/commands/run/test_call.py
index 70bb7618..6145f9f7 100644
--- a/tests/commands/run/test_call.py
+++ b/tests/commands/run/test_call.py
@@ -24,6 +24,8 @@ def test_no_args_one_app(run_command, first_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -53,6 +55,8 @@ def test_no_args_one_app_with_passthrough(run_command, first_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools have been verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -100,6 +104,8 @@ def test_with_arg_one_app(run_command, first_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "first"),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -129,6 +135,8 @@ def test_with_arg_two_apps(run_command, first_app, second_app):
         ("verify-tools",),
         # App config has been finalized
         ("finalize-app-config", "second"),
+        # App template is verified
+        ("verify-app-template", "second"),
         # App tools have been verified
         ("verify-app-tools", "second"),
         # Run the second app
@@ -191,6 +199,8 @@ def test_create_app_before_start(run_command, first_app_config):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -236,6 +246,8 @@ def test_build_app_before_start(run_command, first_app_unbuilt):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -281,6 +293,8 @@ def test_update_app(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -326,6 +340,8 @@ def test_update_app_requirements(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -371,6 +387,8 @@ def test_update_app_resources(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -417,6 +435,8 @@ def test_update_unbuilt_app(run_command, first_app_unbuilt):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -463,6 +483,8 @@ def test_update_non_existent(run_command, first_app_config):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
@@ -508,6 +530,8 @@ def test_test_mode_existing_app(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -553,6 +577,8 @@ def test_test_mode_existing_app_with_passthrough(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools have been verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -590,6 +616,8 @@ def test_test_mode_existing_app_no_update(run_command, first_app):
         # App config has been finalized
         ("finalize-app-config", "first"),
         # App will not be built; update is disabled
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -635,6 +663,8 @@ def test_test_mode_existing_app_update_requirements(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -680,6 +710,8 @@ def test_test_mode_existing_app_update_resources(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -725,6 +757,8 @@ def test_test_mode_update_existing_app(run_command, first_app):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Run the first app
@@ -770,6 +804,8 @@ def test_test_mode_non_existent(run_command, first_app_config):
                 "no_update": False,
             },
         ),
+        # App template is verified
+        ("verify-app-template", "first"),
         # App tools are verified
         ("verify-app-tools", "first"),
         # Then, it will be started
diff --git a/tests/commands/update/conftest.py b/tests/commands/update/conftest.py
index 6290b7bb..6c2a4e77 100644
--- a/tests/commands/update/conftest.py
+++ b/tests/commands/update/conftest.py
@@ -25,6 +25,10 @@ class DummyUpdateCommand(UpdateCommand):
 
         self.actions = []
 
+    def briefcase_toml(self, app):
+        # default any app to an empty `briefcase.toml`
+        return self._briefcase_toml.get(app, {})
+
     def binary_path(self, app):
         return self.bundle_path(app) / f"{app.app_name}.bin"
 
@@ -40,6 +44,10 @@ class DummyUpdateCommand(UpdateCommand):
         super().finalize_app_config(app=app)
         self.actions.append(("finalize-app-config", app.app_name))
 
+    def verify_app_template(self, app):
+        super().verify_app_template(app=app)
+        self.actions.append(("verify-app-template", app.app_name))
+
     def verify_app_tools(self, app):
         super().verify_app_tools(app=app)
         self.actions.append(("verify-app-tools", app.app_name))
diff --git a/tests/commands/update/test_call.py b/tests/commands/update/test_call.py
index 2e92fae7..cd48396e 100644
--- a/tests/commands/update/test_call.py
+++ b/tests/commands/update/test_call.py
@@ -36,10 +36,12 @@ def test_update(update_command, first_app, second_app):
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
         # Update the first app
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("cleanup", "first"),
         # Update the second app
+        ("verify-app-template", "second"),
         ("verify-app-tools", "second"),
         ("code", "second", False),
         ("cleanup", "second"),
@@ -62,6 +64,7 @@ def test_update_single(update_command, first_app, second_app):
         # App config has been finalized
         ("finalize-app-config", "first"),
         # update the first app
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("cleanup", "first"),
@@ -85,11 +88,13 @@ def test_update_with_requirements(update_command, first_app, second_app):
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
         # Update the first app
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("requirements", "first", False),
         ("cleanup", "first"),
         # Update the second app
+        ("verify-app-template", "second"),
         ("verify-app-tools", "second"),
         ("code", "second", False),
         ("requirements", "second", False),
@@ -114,11 +119,13 @@ def test_update_with_resources(update_command, first_app, second_app):
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
         # Update the first app
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("resources", "first"),
         ("cleanup", "first"),
         # Update the second app
+        ("verify-app-template", "second"),
         ("verify-app-tools", "second"),
         ("code", "second", False),
         ("resources", "second"),
@@ -143,12 +150,14 @@ def test_update_with_support(update_command, first_app, second_app):
         ("finalize-app-config", "first"),
         ("finalize-app-config", "second"),
         # Update the first app
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("cleanup-support", "first"),
         ("support", "first"),
         ("cleanup", "first"),
         # Update the second app
+        ("verify-app-template", "second"),
         ("verify-app-tools", "second"),
         ("code", "second", False),
         ("cleanup-support", "second"),
diff --git a/tests/commands/update/test_update_app.py b/tests/commands/update/test_update_app.py
index fb55a417..78be976e 100644
--- a/tests/commands/update/test_update_app.py
+++ b/tests/commands/update/test_update_app.py
@@ -10,6 +10,7 @@ def test_update_app(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("cleanup", "first"),
@@ -67,6 +68,7 @@ def test_update_app_with_requirements(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("requirements", "first", False),
@@ -102,6 +104,7 @@ def test_update_app_with_resources(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("resources", "first"),
@@ -137,6 +140,7 @@ def test_update_app_with_support_package(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", False),
         ("cleanup-support", "first"),
@@ -177,6 +181,7 @@ def test_update_app_test_mode(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", True),
         ("cleanup", "first"),
@@ -212,6 +217,7 @@ def test_update_app_test_mode_requirements(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", True),
         ("requirements", "first", True),
@@ -248,6 +254,7 @@ def test_update_app_test_mode_resources(update_command, first_app, tmp_path):
 
     # The right sequence of things will be done
     assert update_command.actions == [
+        ("verify-app-template", "first"),
         ("verify-app-tools", "first"),
         ("code", "first", True),
         ("resources", "first"),
diff --git a/tests/integrations/java/test_JDK_upgrade.py b/tests/integrations/java/test_JDK__upgrade.py
similarity index 85%
rename from tests/integrations/java/test_JDK_upgrade.py
rename to tests/integrations/java/test_JDK__upgrade.py
index 144ad971..bc087a73 100644
--- a/tests/integrations/java/test_JDK_upgrade.py
+++ b/tests/integrations/java/test_JDK__upgrade.py
@@ -17,6 +17,7 @@ from briefcase.integrations.java import JDK
 @pytest.fixture
 def mock_tools(mock_tools) -> ToolCache:
     mock_tools.host_os = "Linux"
+    mock_tools.host_arch = "x86_64"
     return mock_tools
 
 
@@ -47,7 +48,7 @@ def test_non_existing_install(mock_tools, tmp_path):
 
 
 def test_existing_install(mock_tools, tmp_path):
-    """If there's an existing managed JDK install, it is deleted and redownloaded."""
+    """If there's an existing managed JDK install, it is deleted and re-downloaded."""
     # Create a mock of a previously installed Java version.
     java_home = tmp_path / "tools" / "java"
     (java_home / "bin").mkdir(parents=True)
@@ -66,7 +67,7 @@ def test_existing_install(mock_tools, tmp_path):
     mock_tools.download.file.return_value = archive
 
     # Create a directory to make it look like Java was downloaded and unpacked.
-    (tmp_path / "tools" / "jdk8u242-b08").mkdir(parents=True)
+    (tmp_path / "tools" / "jdk-17.0.7+7").mkdir(parents=True)
 
     # Create an SDK wrapper
     jdk = JDK(mock_tools, java_home=java_home)
@@ -79,10 +80,10 @@ def test_existing_install(mock_tools, tmp_path):
 
     # A download was initiated
     mock_tools.download.file.assert_called_with(
-        url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-        "jdk8u242-b08/OpenJDK8U-jdk_x64_linux_hotspot_8u242b08.tar.gz",
+        url="https://github.com/adoptium/temurin17-binaries/releases/download/"
+        "jdk-17.0.7+7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz",
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
 
     # The archive was unpacked.
@@ -95,8 +96,8 @@ def test_existing_install(mock_tools, tmp_path):
 
 
 def test_macOS_existing_install(mock_tools, tmp_path):
-    """If there's an existing managed macOS JDK install, it is deleted and
-    redownloaded."""
+    """If there's an existing managed macOS JDK install, it is deleted and re-
+    downloaded."""
     # Force mocking on macOS
     mock_tools.host_os = "Darwin"
 
@@ -118,7 +119,7 @@ def test_macOS_existing_install(mock_tools, tmp_path):
     mock_tools.download.file.return_value = archive
 
     # Create a directory to make it look like Java was downloaded and unpacked.
-    (tmp_path / "tools" / "jdk8u242-b08").mkdir(parents=True)
+    (tmp_path / "tools" / "jdk-17.0.7+7").mkdir(parents=True)
 
     # Create an SDK wrapper
     jdk = JDK(mock_tools, java_home=java_home)
@@ -131,23 +132,24 @@ def test_macOS_existing_install(mock_tools, tmp_path):
 
     # A download was initiated
     mock_tools.download.file.assert_called_with(
-        url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-        "jdk8u242-b08/OpenJDK8U-jdk_x64_mac_hotspot_8u242b08.tar.gz",
+        url="https://github.com/adoptium/temurin17-binaries/releases/download/"
+        "jdk-17.0.7+7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.7_7.tar.gz",
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
 
     # The archive was unpacked.
     # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
     mock_tools.shutil.unpack_archive.assert_called_with(
-        "/path/to/download.zip", extract_dir=os.fsdecode(tmp_path / "tools")
+        "/path/to/download.zip",
+        extract_dir=os.fsdecode(tmp_path / "tools"),
     )
     # The original archive was deleted
     archive.unlink.assert_called_once_with()
 
 
 def test_download_fail(mock_tools, tmp_path):
-    """If there's an existing managed JDK install, it is deleted and redownloaded."""
+    """If there's an existing managed JDK install, it is deleted and re-downloaded."""
     # Create a mock of a previously installed Java version.
     java_home = tmp_path / "tools" / "java"
     (java_home / "bin").mkdir(parents=True)
@@ -173,10 +175,10 @@ def test_download_fail(mock_tools, tmp_path):
 
     # A download was initiated
     mock_tools.download.file.assert_called_with(
-        url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-        "jdk8u242-b08/OpenJDK8U-jdk_x64_linux_hotspot_8u242b08.tar.gz",
+        url="https://github.com/adoptium/temurin17-binaries/releases/download/"
+        "jdk-17.0.7+7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz",
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
 
     # No attempt was made to unpack the archive
@@ -184,7 +186,7 @@ def test_download_fail(mock_tools, tmp_path):
 
 
 def test_unpack_fail(mock_tools, tmp_path):
-    """If there's an existing managed JDK install, it is deleted and redownloaded."""
+    """If there's an existing managed JDK install, it is deleted and re-downloaded."""
     # Create a mock of a previously installed Java version.
     java_home = tmp_path / "tools" / "java"
     (java_home / "bin").mkdir(parents=True)
@@ -217,16 +219,17 @@ def test_unpack_fail(mock_tools, tmp_path):
 
     # A download was initiated
     mock_tools.download.file.assert_called_with(
-        url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-        "jdk8u242-b08/OpenJDK8U-jdk_x64_linux_hotspot_8u242b08.tar.gz",
+        url="https://github.com/adoptium/temurin17-binaries/releases/download/"
+        "jdk-17.0.7+7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz",
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
 
     # The archive was unpacked.
     # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
     mock_tools.shutil.unpack_archive.assert_called_with(
-        "/path/to/download.zip", extract_dir=os.fsdecode(tmp_path / "tools")
+        "/path/to/download.zip",
+        extract_dir=os.fsdecode(tmp_path / "tools"),
     )
     # The original archive was not deleted
     assert archive.unlink.call_count == 0
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index 2eca486a..36d6886b 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -2,7 +2,6 @@ import os
 import shutil
 import subprocess
 from pathlib import Path
-from subprocess import CalledProcessError
 from unittest import mock
 
 import pytest
@@ -16,11 +15,6 @@ from briefcase.exceptions import (
 from briefcase.integrations.java import JDK
 
 CALL_JAVA_HOME = mock.call(["/usr/libexec/java_home"])
-CALL_ROSETTA_CHECK = mock.call(["arch", "-x86_64", "true"])
-CALL_ROSETTA_INSTALL = mock.call(
-    ["softwareupdate", "--install-rosetta", "--agree-to-license"],
-    check=True,
-)
 
 
 def test_short_circuit(mock_tools):
@@ -49,13 +43,10 @@ def test_macos_tool_java_home(mock_tools, capsys):
     # Mock being on macOS
     mock_tools.host_os = "Darwin"
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Mock 2 calls to check_output.
     mock_tools.subprocess.check_output.side_effect = [
         "/path/to/java",
-        "javac 1.8.0_144\n",
+        "javac 17.0.7\n",
     ]
 
     # Create a JDK wrapper by verification
@@ -81,22 +72,19 @@ def test_macos_tool_failure(mock_tools, tmp_path, capsys):
     # Mock being on macOS
     mock_tools.host_os = "Darwin"
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Mock a failed call on the libexec tool
     mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         returncode=1, cmd="/usr/libexec/java_home"
     )
 
     # Create a directory to make it look like the Briefcase Java already exists.
-    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
+    (tmp_path / "tools" / "java17" / "Contents" / "Home" / "bin").mkdir(parents=True)
 
     # Create a JDK wrapper by verification
     jdk = JDK.verify(mock_tools)
 
     # The JDK should have the briefcase JAVA_HOME
-    assert jdk.java_home == tmp_path / "tools" / "java" / "Contents" / "Home"
+    assert jdk.java_home == tmp_path / "tools" / "java17" / "Contents" / "Home"
 
     assert mock_tools.subprocess.check_output.mock_calls == [CALL_JAVA_HOME]
 
@@ -111,14 +99,11 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys):
     # Mock being on macOS
     mock_tools.host_os = "Darwin"
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"}
 
     # Mock return value from javac. libexec won't be invoked.
-    mock_tools.subprocess.check_output.return_value = "javac 1.8.0_144\n"
+    mock_tools.subprocess.check_output.return_value = "javac 17.0.7\n"
 
     # Create a JDK wrapper by verification
     JDK.verify(mock_tools)
@@ -139,14 +124,12 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys):
 
 def test_valid_provided_java_home(mock_tools, capsys):
     """If a valid JAVA_HOME is provided, it is used."""
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
 
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"}
 
     # Mock return value from javac.
-    mock_tools.subprocess.check_output.return_value = "javac 1.8.0_144\n"
+    mock_tools.subprocess.check_output.return_value = "javac 17.0.7\n"
 
     # Create a JDK wrapper by verification
     JDK.verify(mock_tools)
@@ -168,9 +151,9 @@ def test_valid_provided_java_home(mock_tools, capsys):
 @pytest.mark.parametrize(
     "host_os, java_home",
     [
-        ("Linux", Path("tools", "java")),
-        ("Windows", Path("tools", "java")),
-        ("Darwin", Path("tools", "java", "Contents", "Home")),
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
     ],
 )
 def test_invalid_jdk_version(mock_tools, host_os, java_home, tmp_path, capsys):
@@ -179,9 +162,6 @@ def test_invalid_jdk_version(mock_tools, host_os, java_home, tmp_path, capsys):
     # Mock os
     mock_tools.host_os = host_os
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"}
 
@@ -211,12 +191,12 @@ def test_invalid_jdk_version(mock_tools, host_os, java_home, tmp_path, capsys):
 @pytest.mark.parametrize(
     "host_os, java_home, error_type",
     [
-        ("Linux", Path("tools", "java"), FileNotFoundError),
-        ("Linux", Path("tools", "java"), NotADirectoryError),
-        ("Windows", Path("tools", "java"), FileNotFoundError),
-        ("Windows", Path("tools", "java"), NotADirectoryError),
-        ("Darwin", Path("tools", "java", "Contents", "Home"), FileNotFoundError),
-        ("Darwin", Path("tools", "java", "Contents", "Home"), NotADirectoryError),
+        ("Linux", Path("tools", "java17"), FileNotFoundError),
+        ("Linux", Path("tools", "java17"), NotADirectoryError),
+        ("Windows", Path("tools", "java17"), FileNotFoundError),
+        ("Windows", Path("tools", "java17"), NotADirectoryError),
+        ("Darwin", Path("tools", "java17", "Contents", "Home"), FileNotFoundError),
+        ("Darwin", Path("tools", "java17", "Contents", "Home"), NotADirectoryError),
     ],
 )
 def test_no_javac(mock_tools, host_os, java_home, error_type, tmp_path, capsys):
@@ -225,9 +205,6 @@ def test_no_javac(mock_tools, host_os, java_home, error_type, tmp_path, capsys):
     # Mock os
     mock_tools.host_os = host_os
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"}
 
@@ -260,9 +237,9 @@ def test_no_javac(mock_tools, host_os, java_home, error_type, tmp_path, capsys):
 @pytest.mark.parametrize(
     "host_os, java_home",
     [
-        ("Linux", Path("tools", "java")),
-        ("Windows", Path("tools", "java")),
-        ("Darwin", Path("tools", "java", "Contents", "Home")),
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
     ],
 )
 def test_javac_error(mock_tools, host_os, java_home, tmp_path, capsys):
@@ -270,9 +247,6 @@ def test_javac_error(mock_tools, host_os, java_home, tmp_path, capsys):
     # Mock os
     mock_tools.host_os = host_os
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"}
 
@@ -304,9 +278,9 @@ def test_javac_error(mock_tools, host_os, java_home, tmp_path, capsys):
 @pytest.mark.parametrize(
     "host_os, java_home",
     [
-        ("Linux", Path("tools", "java")),
-        ("Windows", Path("tools", "java")),
-        ("Darwin", Path("tools", "java", "Contents", "Home")),
+        ("Linux", Path("tools", "java17")),
+        ("Windows", Path("tools", "java17")),
+        ("Darwin", Path("tools", "java17", "Contents", "Home")),
     ],
 )
 def test_unparseable_javac_version(mock_tools, host_os, java_home, tmp_path, capsys):
@@ -314,9 +288,6 @@ def test_unparseable_javac_version(mock_tools, host_os, java_home, tmp_path, cap
     # Mock os
     mock_tools.host_os = host_os
 
-    # Prevent Rosetta check.
-    mock_tools.host_arch = "x86_64"
-
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"}
 
@@ -344,25 +315,49 @@ def test_unparseable_javac_version(mock_tools, host_os, java_home, tmp_path, cap
 
 
 @pytest.mark.parametrize(
-    "host_os, jdk_url, jhome",
+    "host_os, host_arch, jdk_url, jhome",
     [
         (
             "Darwin",
-            "https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-            "jdk8u242-b08/OpenJDK8U-jdk_x64_mac_hotspot_8u242b08.tar.gz",
-            "java/Contents/Home",
+            "x86_64",
+            "https://github.com/adoptium/temurin17-binaries/releases/download/"
+            "jdk-17.0.7+7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.7_7.tar.gz",
+            "java17/Contents/Home",
+        ),
+        (
+            "Darwin",
+            "aarch64",
+            "https://github.com/adoptium/temurin17-binaries/releases/download/"
+            "jdk-17.0.7+7/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.7_7.tar.gz",
+            "java17/Contents/Home",
+        ),
+        (
+            "Linux",
+            "x86_64",
+            "https://github.com/adoptium/temurin17-binaries/releases/download/"
+            "jdk-17.0.7+7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz",
+            "java17",
+        ),
+        (
+            "Linux",
+            "aarch64",
+            "https://github.com/adoptium/temurin17-binaries/releases/download/"
+            "jdk-17.0.7+7/OpenJDK17U-jdk_aarch64_linux_hotspot_17.0.7_7.tar.gz",
+            "java17",
         ),
         (
             "Linux",
-            "https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-            "jdk8u242-b08/OpenJDK8U-jdk_x64_linux_hotspot_8u242b08.tar.gz",
-            "java",
+            "armv6l",
+            "https://github.com/adoptium/temurin17-binaries/releases/download/"
+            "jdk-17.0.7+7/OpenJDK17U-jdk_arm_linux_hotspot_17.0.7_7.tar.gz",
+            "java17",
         ),
         (
             "Windows",
-            "https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-            "jdk8u242-b08/OpenJDK8U-jdk_x64_windows_hotspot_8u242b08.zip",
-            "java",
+            "AMD64",
+            "https://github.com/adoptium/temurin17-binaries/releases/download/"
+            "jdk-17.0.7+7/OpenJDK17U-jdk_x64_windows_hotspot_17.0.7_7.zip",
+            "java17",
         ),
     ],
 )
@@ -371,12 +366,14 @@ def test_successful_jdk_download(
     tmp_path,
     capsys,
     host_os,
+    host_arch,
     jdk_url,
     jhome,
 ):
     """If needed, a JDK can be downloaded."""
-    # Mock host OS
+    # Mock host OS and arch
     mock_tools.host_os = host_os
+    mock_tools.host_arch = host_arch
 
     # Mock a JAVA_HOME that won't exist
     # This is only needed to make macOS *not* run /usr/libexec/java_home
@@ -390,7 +387,7 @@ def test_successful_jdk_download(
     mock_tools.download.file.return_value = archive
 
     # Create a directory to make it look like Java was downloaded and unpacked.
-    (tmp_path / "tools" / "jdk8u242-b08").mkdir(parents=True)
+    (tmp_path / "tools" / "jdk-17.0.7+7").mkdir(parents=True)
 
     # Invoke the verify call
     JDK.verify(mock_tools)
@@ -400,13 +397,13 @@ def test_successful_jdk_download(
     # Console output contains a warning about the bad JDK location
     output = capsys.readouterr()
     assert output.err == ""
-    assert "** WARNING: JAVA_HOME does not point to a Java 8 JDK" in output.out
+    assert "** WARNING: JAVA_HOME does not point to a Java 17 JDK" in output.out
 
     # Download was invoked
     mock_tools.download.file.assert_called_with(
         url=jdk_url,
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
     # The archive was unpacked
     # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
@@ -432,8 +429,9 @@ def test_not_installed(mock_tools, tmp_path):
 
 def test_jdk_download_failure(mock_tools, tmp_path):
     """If an error occurs downloading the JDK, an error is raised."""
-    # Mock Linux as the host
+    # Mock Linux x86_64 as the host
     mock_tools.host_os = "Linux"
+    mock_tools.host_arch = "x86_64"
 
     # Mock a failure on download
     mock_tools.download.file.side_effect = NetworkFailure("mock")
@@ -444,10 +442,10 @@ def test_jdk_download_failure(mock_tools, tmp_path):
 
     # That download was attempted
     mock_tools.download.file.assert_called_with(
-        url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-        "jdk8u242-b08/OpenJDK8U-jdk_x64_linux_hotspot_8u242b08.tar.gz",
+        url="https://github.com/adoptium/temurin17-binaries/releases/download/"
+        "jdk-17.0.7+7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz",
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
     # No attempt was made to unpack the archive
     assert mock_tools.shutil.unpack_archive.call_count == 0
@@ -455,8 +453,9 @@ def test_jdk_download_failure(mock_tools, tmp_path):
 
 def test_invalid_jdk_archive(mock_tools, tmp_path):
     """If the JDK download isn't a valid archive, raise an error."""
-    # Mock Linux as the host
+    # Mock Linux x86_64 as the host
     mock_tools.host_os = "Linux"
+    mock_tools.host_arch = "x86_64"
 
     # Mock the cached download path
     # Consider to remove if block when we drop py3.7 support, only keep statements from else.
@@ -473,109 +472,16 @@ def test_invalid_jdk_archive(mock_tools, tmp_path):
 
     # The download occurred
     mock_tools.download.file.assert_called_with(
-        url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/"
-        "jdk8u242-b08/OpenJDK8U-jdk_x64_linux_hotspot_8u242b08.tar.gz",
+        url="https://github.com/adoptium/temurin17-binaries/releases/download/"
+        "jdk-17.0.7+7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz",
         download_path=tmp_path / "tools",
-        role="Java 8 JDK",
+        role="Java 17 JDK",
     )
     # An attempt was made to unpack the archive.
     # TODO: Py3.6 compatibility; os.fsdecode not required in Py3.7
     mock_tools.shutil.unpack_archive.assert_called_with(
-        "/path/to/download.zip", extract_dir=os.fsdecode(tmp_path / "tools")
+        "/path/to/download.zip",
+        extract_dir=os.fsdecode(tmp_path / "tools"),
     )
     # The original archive was not deleted
     assert archive.unlink.call_count == 0
-
-
-def test_rosetta_host_os(mock_tools, tmp_path):
-    """On an OS other than macOS, the Rosetta check does not occur."""
-    mock_tools.host_os = "Linux"
-    mock_tools.host_arch = "arm64"
-
-    # Create a mock of a previously installed Java version.
-    (tmp_path / "tools" / "java" / "bin").mkdir(parents=True)
-
-    JDK.verify(mock_tools)
-    mock_tools.subprocess.check_output.assert_not_called()
-    mock_tools.subprocess.run.assert_not_called()
-
-
-def test_rosetta_host_arch(mock_tools, tmp_path):
-    """On an architecture other than ARM64, the Rosetta check does not occur."""
-    mock_tools.host_os = "Darwin"
-    mock_tools.host_arch = "x86_64"
-
-    mock_tools.subprocess.check_output.side_effect = [
-        CalledProcessError(1, "java_home")
-    ]
-
-    # Create a mock of a previously installed Java version.
-    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
-
-    JDK.verify(mock_tools)
-    assert mock_tools.subprocess.check_output.mock_calls == [CALL_JAVA_HOME]
-    mock_tools.subprocess.run.assert_not_called()
-
-
-def test_rosetta_already_installed(mock_tools, tmp_path):
-    """On an ARM Mac, the Rosetta check occurs before calling any other Java
-    commands."""
-    mock_tools.host_os = "Darwin"
-    mock_tools.host_arch = "arm64"
-
-    mock_tools.subprocess.check_output.side_effect = [
-        None,  # Rosetta check succeeds.
-        CalledProcessError(1, "java_home"),
-    ]
-
-    # Create a mock of a previously installed Java version.
-    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
-
-    JDK.verify(mock_tools)
-    assert mock_tools.subprocess.check_output.mock_calls == [
-        CALL_ROSETTA_CHECK,
-        CALL_JAVA_HOME,
-    ]
-    mock_tools.subprocess.run.assert_not_called()
-
-
-def test_rosetta_install_success(mock_tools, tmp_path):
-    """Rosetta is installed if necessary."""
-    mock_tools.host_os = "Darwin"
-    mock_tools.host_arch = "arm64"
-
-    mock_tools.subprocess.check_output.side_effect = [
-        CalledProcessError(1, "arch"),
-        CalledProcessError(1, "java_home"),
-    ]
-
-    # Create a mock of a previously installed Java version.
-    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
-
-    JDK.verify(mock_tools)
-    assert mock_tools.subprocess.check_output.mock_calls == [
-        CALL_ROSETTA_CHECK,
-        CALL_JAVA_HOME,
-    ]
-    assert mock_tools.subprocess.run.mock_calls == [CALL_ROSETTA_INSTALL]
-
-
-def test_rosetta_install_failure(mock_tools, tmp_path):
-    """If Rosetta install fails, no Java commands are called."""
-    mock_tools.host_os = "Darwin"
-    mock_tools.host_arch = "arm64"
-
-    mock_tools.subprocess.check_output.side_effect = [
-        CalledProcessError(1, "arch"),
-    ]
-    mock_tools.subprocess.run.side_effect = [
-        CalledProcessError(1, "softwareupdate"),
-    ]
-
-    # Create a mock of a previously installed Java version.
-    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
-
-    with pytest.raises(BriefcaseCommandError, match="Failed to install Rosetta"):
-        JDK.verify(mock_tools)
-    assert mock_tools.subprocess.check_output.mock_calls == [CALL_ROSETTA_CHECK]
-    assert mock_tools.subprocess.run.mock_calls == [CALL_ROSETTA_INSTALL]
diff --git a/tests/platforms/android/gradle/test_build.py b/tests/platforms/android/gradle/test_build.py
index 6fa09edf..f3b3bbb5 100644
--- a/tests/platforms/android/gradle/test_build.py
+++ b/tests/platforms/android/gradle/test_build.py
@@ -30,6 +30,23 @@ def build_command(tmp_path, first_app_generated):
     return command
 
 
+def test_unsupported_template_version(build_command, first_app_generated):
+    """Error raised if template's target version is not supported."""
+    build_command.verify_app = MagicMock(wraps=build_command.verify_app)
+
+    build_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        build_command(first_app_generated)
+
+    build_command.verify_app.assert_called_once_with(first_app_generated)
+
+
 @pytest.mark.parametrize(
     "host_os, gradlew_name",
     [("Windows", "gradlew.bat"), ("NonWindows", "gradlew")],
diff --git a/tests/platforms/android/gradle/test_create.py b/tests/platforms/android/gradle/test_create.py
index 78c07263..2a5927e7 100644
--- a/tests/platforms/android/gradle/test_create.py
+++ b/tests/platforms/android/gradle/test_create.py
@@ -1,9 +1,10 @@
 import sys
+from unittest.mock import MagicMock
 
 import pytest
 
 from briefcase.console import Console, Log
-from briefcase.exceptions import UnsupportedHostError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.platforms.android.gradle import GradleCreateCommand
 
 
@@ -27,6 +28,30 @@ def test_unsupported_host_os(create_command, host_os):
         create_command()
 
 
+def test_unsupported_template_version(create_command, first_app_config):
+    """Error raised if template's target version is not supported."""
+    # Skip rolling out the template and support package
+    create_command.generate_app_template = MagicMock()
+    create_command.install_app_support_package = MagicMock()
+
+    # Skip tool verification
+    create_command.verify_tools = MagicMock()
+
+    create_command.verify_app = MagicMock(wraps=create_command.verify_app)
+
+    create_command._briefcase_toml.update(
+        {first_app_config: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        create_command(first_app_config)
+
+    create_command.verify_app.assert_called_once_with(first_app_config)
+
+
 def test_support_package_filename(create_command):
     """The Android support package filename has been customized."""
     assert (
diff --git a/tests/platforms/android/gradle/test_open.py b/tests/platforms/android/gradle/test_open.py
index f516e4f0..a36bdd14 100644
--- a/tests/platforms/android/gradle/test_open.py
+++ b/tests/platforms/android/gradle/test_open.py
@@ -1,10 +1,12 @@
 import os
 import sys
+from collections import defaultdict
 from unittest.mock import MagicMock
 
 import pytest
 
 from briefcase.console import Console, Log
+from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.download import Download
 from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.android.gradle import GradleOpenCommand
@@ -38,6 +40,11 @@ def open_command(tmp_path, first_app_config):
     command.tools.subprocess = MagicMock(spec_set=Subprocess)
     command.tools.download = MagicMock(spec_set=Download)
 
+    # Mock all apps as targeting version 0.3.15
+    command._briefcase_toml = defaultdict(
+        lambda: {"briefcase": {"target_version": "0.3.15"}}
+    )
+
     # Mock some OS calls needed to make the tools appear to exist
     command.tools.os.environ = {}
     command.tools.os.access.return_value = True
@@ -56,11 +63,31 @@ def open_command(tmp_path, first_app_config):
     return command
 
 
+def test_unsupported_template_version(open_command, first_app_generated, tmp_path):
+    """Error raised if template's target version is not supported."""
+    # Skip tool verification
+    open_command.verify_tools = MagicMock()
+
+    open_command.verify_app = MagicMock(wraps=open_command.verify_app)
+
+    open_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        open_command(first_app_generated)
+
+    open_command.verify_app.assert_called_once_with(first_app_generated)
+
+
 @pytest.mark.skipif(sys.platform != "darwin", reason="macOS specific test")
 def test_open_macOS(open_command, first_app_config, tmp_path):
     """On macOS, open uses Finder to open the project folder."""
     # Mock the call to verify the existence of java
-    open_command.tools.subprocess.check_output.return_value = "javac 1.8.0_144\n"
+    open_command.tools.subprocess.check_output.return_value = "javac 17.0.7\n"
 
     # Create the project folder to mock a created project.
     open_command.project_path(first_app_config).mkdir(parents=True)
@@ -85,7 +112,7 @@ def test_open_linux(open_command, first_app_config, tmp_path):
     open_command.project_path(first_app_config).mkdir(parents=True)
 
     # Create a stub java binary
-    create_file(tmp_path / "briefcase" / "tools" / "java" / "bin" / "java", "java")
+    create_file(tmp_path / "briefcase" / "tools" / "java17" / "bin" / "java", "java")
 
     # Create a stub sdkmanager
     create_sdk_manager(tmp_path)
@@ -107,7 +134,7 @@ def test_open_windows(open_command, first_app_config, tmp_path):
     open_command.project_path(first_app_config).mkdir(parents=True)
 
     # Create a stub java binary
-    create_file(tmp_path / "briefcase" / "tools" / "java" / "bin" / "java", "java")
+    create_file(tmp_path / "briefcase" / "tools" / "java17" / "bin" / "java", "java")
 
     # Create a stub sdkmanager
     create_sdk_manager(tmp_path, extension=".bat")
diff --git a/tests/platforms/android/gradle/test_package.py b/tests/platforms/android/gradle/test_package.py
index 8d666d09..e89ea5c7 100644
--- a/tests/platforms/android/gradle/test_package.py
+++ b/tests/platforms/android/gradle/test_package.py
@@ -35,6 +35,26 @@ def package_command(tmp_path, first_app_config):
     return command
 
 
+def test_unsupported_template_version(package_command, first_app_generated):
+    """Error raised if template's target version is not supported."""
+    # Mock the build command previously called
+    create_file(package_command.binary_path(first_app_generated), content="")
+
+    package_command.verify_app = MagicMock(wraps=package_command.verify_app)
+
+    package_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        package_command(first_app_generated, packaging_format="aab")
+
+    package_command.verify_app.assert_called_once_with(first_app_generated)
+
+
 def test_packaging_formats(package_command):
     assert package_command.packaging_formats == ["aab"]
 
@@ -55,7 +75,11 @@ def test_distribution_path(package_command, first_app_config, tmp_path):
     [("Windows", "gradlew.bat"), ("NonWindows", "gradlew")],
 )
 def test_execute_gradle(
-    package_command, first_app_config, host_os, gradlew_name, tmp_path
+    package_command,
+    first_app_config,
+    host_os,
+    gradlew_name,
+    tmp_path,
 ):
     """Validate that package_app() will launch `gradlew bundleRelease` with the
     appropriate environment & cwd, and that it will use `gradlew.bat` on Windows but
@@ -86,7 +110,9 @@ def test_execute_gradle(
     # Create mock environment with `key`, which we expect to be preserved, and
     # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
     package_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
+
     package_command.package_app(first_app_config)
+
     package_command.tools.android_sdk.verify_emulator.assert_called_once_with()
     package_command.tools.subprocess.run.assert_called_once_with(
         [
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index d6ef6655..aa7aaf14 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -20,6 +20,8 @@ from briefcase.platforms.android.gradle import (
     android_log_clean_filter,
 )
 
+from ....utils import create_file
+
 
 @pytest.fixture
 def jdk():
@@ -135,6 +137,31 @@ def test_shutdown_on_exit_option(run_command):
     }
 
 
+def test_unsupported_template_version(run_command, first_app_generated):
+    """Error raised if template's target version is not supported."""
+    run_command.apps = {"first-app": first_app_generated}
+
+    # Skip verifying tools
+    run_command.verify_tools = mock.MagicMock()
+
+    # Mock the build command previously called
+    create_file(run_command.binary_path(first_app_generated), content="")
+
+    run_command.verify_app = mock.MagicMock(wraps=run_command.verify_app)
+
+    run_command._briefcase_toml.update(
+        {first_app_generated: {"briefcase": {"target_epoch": "0.3.16"}}}
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="The app template used to generate this app is not compatible",
+    ):
+        run_command(first_app_generated.app_name)
+
+    run_command.verify_app.assert_called_once_with(first_app_generated)
+
+
 def test_run_existing_device(run_command, first_app_config):
     """An app can be run on an existing device."""
     # Set up device selection to return a running physical device.
diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py
index e16fb1c6..c2d78db5 100644
--- a/tests/platforms/linux/appimage/test_build.py
+++ b/tests/platforms/linux/appimage/test_build.py
@@ -58,8 +58,8 @@ def build_command(tmp_path, first_app_config):
     command.tools.host_os = "Linux"
     command.tools.host_arch = "wonky"
     command.use_docker = False
-    command._path_index = {
-        first_app_config: {
+    command._briefcase_toml[first_app_config] = {
+        "paths": {
             "app_path": "First App.AppDir/usr/app",
             "app_packages_path": "First App.AppDir/usr/app_packages",
         }
diff --git a/tests/platforms/linux/test_LocalRequirementsMixin.py b/tests/platforms/linux/test_LocalRequirementsMixin.py
index c68e8e6b..50e4887c 100644
--- a/tests/platforms/linux/test_LocalRequirementsMixin.py
+++ b/tests/platforms/linux/test_LocalRequirementsMixin.py
@@ -50,8 +50,8 @@ def no_docker_create_command(first_app_config, tmp_path):
     # Mock shutil.copy to do the copy, but be observable
     command.tools.shutil.copy = MagicMock(side_effect=shutil.copy)
 
-    command._path_index = {
-        first_app_config: {"app_packages_path": "path/to/app_packages"}
+    command._briefcase_toml[first_app_config] = {
+        "paths": {"app_packages_path": "path/to/app_packages"}
     }
 
     # At the time app requirements are installed, the project folder will exist.