From f72e940307e0c746d61e29ca9d61891d08048335 Mon Sep 17 00:00:00 2001 From: Andre Miras Date: Sun, 17 May 2026 19:52:07 +0000 Subject: [PATCH] :safety_vest: Guard Android versionCode values Validate generated and explicit Android versionCode values before rendering Gradle or manifest templates. This keeps valid legacy generated values unchanged while failing early when versionCode is non-integer, non-positive, or above Google Play's documented 2100000000 maximum. Document the versionName/versionCode split with a reference to the Android versioning docs, and cover the helper behavior with focused bootstrap build tests. Upstream reference: https://developer.android.com/tools/publishing/versioning --- doc/source/buildoptions.rst | 18 ++- .../bootstraps/common/build/build.py | 106 ++++++++++++++---- tests/test_bootstrap_build.py | 69 ++++++++++-- 3 files changed, 162 insertions(+), 31 deletions(-) diff --git a/doc/source/buildoptions.rst b/doc/source/buildoptions.rst index 167f4fd718..ac1f3040bf 100644 --- a/doc/source/buildoptions.rst +++ b/doc/source/buildoptions.rst @@ -56,7 +56,14 @@ options (this list may not be exhaustive): - ``--private``: The directory containing your project files. - ``--package``: The Java package name for your project. e.g. ``org.example.yourapp``. - ``--name``: The app name. -- ``--version``: The version number. +- ``--version``: The display version shown to users. On Android this is + rendered as ``versionName``. +- ``--numeric-version``: The Android ``versionCode`` used for update ordering. + This must be a positive integer no greater than ``2100000000``. If omitted, + python-for-android computes it from ``--version``. If the computed value is + too large, keep the display version in ``--version`` and set a valid + ``--numeric-version``. See Android's + `versionCode documentation `__. - ``--orientation``: The orientations that the app will display in. (Available options are ``portrait``, ``landscape``, ``portrait-reverse``, ``landscape-reverse``). Since Android ignores ``android:screenOrientation`` when in multi-window mode @@ -145,7 +152,14 @@ ready. - ``--private``: The directory containing your project files. - ``--package``: The Java package name for your project. e.g. ``org.example.yourapp``. - ``--name``: The app name. -- ``--version``: The version number. +- ``--version``: The display version shown to users. On Android this is + rendered as ``versionName``. +- ``--numeric-version``: The Android ``versionCode`` used for update ordering. + This must be a positive integer no greater than ``2100000000``. If omitted, + python-for-android computes it from ``--version``. If the computed value is + too large, keep the display version in ``--version`` and set a valid + ``--numeric-version``. See Android's + `versionCode documentation `__. - ``--orientation``: The orientations that the app will display in. (Available options are ``portrait``, ``landscape``, ``portrait-reverse``, ``landscape-reverse``). Since Android ignores ``android:screenOrientation`` when in multi-window mode diff --git a/pythonforandroid/bootstraps/common/build/build.py b/pythonforandroid/bootstraps/common/build/build.py index e2aacc7ac8..2eb2d555fc 100644 --- a/pythonforandroid/bootstraps/common/build/build.py +++ b/pythonforandroid/bootstraps/common/build/build.py @@ -93,6 +93,73 @@ def get_bootstrap_name(): DEFAULT_PYTHON_ACTIVITY_JAVA_CLASS = 'org.kivy.android.PythonActivity' DEFAULT_PYTHON_SERVICE_JAVA_CLASS = 'org.kivy.android.PythonService' +# Google Play's documented maximum Android versionCode. +# https://developer.android.com/tools/publishing/versioning +MAX_ANDROID_VERSION_CODE = 2100000000 + + +def get_android_numeric_version(version, min_sdk_version): + """ + Generate the default Android versionCode value from --version. + + The format is (10 + minsdk + app_version). Older versioning was + (arch + minsdk + app_version), with arch expressed with a single digit + from 6 to 9. Since multi-arch support, this uses 10. + """ + version_code = 0 + try: + for part in version.split('.'): + version_code *= 100 + version_code += int(part) + except ValueError as exc: + raise ValueError( + "Could not generate Android versionCode from --version " + "{!r}. --version is Android versionName; when it is not numeric " + "dot-separated text, set --numeric-version to a positive Android " + "versionCode integer no greater than {}.".format( + version, MAX_ANDROID_VERSION_CODE + ) + ) from exc + return "{}{}{}".format("10", min_sdk_version, version_code) + + +def validate_android_numeric_version(numeric_version, *, generated_from_version=None): + try: + normalized_version = int(numeric_version) + except (TypeError, ValueError) as exc: + raise ValueError( + "--numeric-version must be a decimal integer Android versionCode " + "greater than 0 and no greater than {}; got {!r}.".format( + MAX_ANDROID_VERSION_CODE, numeric_version + ) + ) from exc + + if normalized_version <= 0: + raise ValueError( + "--numeric-version must be a positive Android versionCode " + "greater than 0; got {!r}.".format(numeric_version) + ) + + if normalized_version > MAX_ANDROID_VERSION_CODE: + if generated_from_version is not None: + raise ValueError( + "Generated Android versionCode {} from --version {!r}, " + "which exceeds the maximum {}. --version is Android " + "versionName; keep this display version by setting " + "--numeric-version to a positive Android versionCode no " + "greater than {}.".format( + normalized_version, + generated_from_version, + MAX_ANDROID_VERSION_CODE, + MAX_ANDROID_VERSION_CODE, + ) + ) + raise ValueError( + "--numeric-version is Android versionCode and must not exceed " + "{}; got {!r}.".format(MAX_ANDROID_VERSION_CODE, numeric_version) + ) + + return str(normalized_version) def render(template, dest, **kwargs): @@ -420,19 +487,17 @@ def make_package(args): versioned_name = (args.name.replace(' ', '').replace('\'', '') + '-' + args.version) - version_code = 0 - if not args.numeric_version: - """ - Set version code in format (10 + minsdk + app_version) - Historically versioning was (arch + minsdk + app_version), - with arch expressed with a single digit from 6 to 9. - Since the multi-arch support, has been changed to 10. - """ - min_sdk = args.min_sdk_version - for i in args.version.split('.'): - version_code *= 100 - version_code += int(i) - args.numeric_version = "{}{}{}".format("10", min_sdk, version_code) + generated_from_version = None + if args.numeric_version is None: + generated_from_version = args.version + args.numeric_version = get_android_numeric_version( + args.version, + args.min_sdk_version, + ) + args.numeric_version = validate_android_numeric_version( + args.numeric_version, + generated_from_version=generated_from_version, + ) if args.intent_filters: with open(args.intent_filters) as fd: @@ -793,14 +858,15 @@ def create_argument_parser(): help=('The human-readable name of the project.'), required=True) ap.add_argument('--numeric-version', dest='numeric_version', - help=('The numeric version number of the project. If not ' - 'given, this is automatically computed from the ' - 'version.')) + help=('The Android versionCode of the project. This must ' + 'be a positive decimal integer no greater than ' + '{}. If not given, it is automatically computed ' + 'from --version.').format(MAX_ANDROID_VERSION_CODE)) ap.add_argument('--version', dest='version', - help=('The version number of the project. This should ' - 'consist of numbers and dots, and should have the ' - 'same number of groups of numbers as previous ' - 'versions.'), + help=('The Android versionName of the project, shown to ' + 'users as the display version. Use ' + '--numeric-version to control Android versionCode ' + 'and update ordering.'), required=True) if is_sdl_bootstrap(): ap.add_argument('--launcher', dest='launcher', action='store_true', diff --git a/tests/test_bootstrap_build.py b/tests/test_bootstrap_build.py index ff5f7dcacc..e78c2e5778 100644 --- a/tests/test_bootstrap_build.py +++ b/tests/test_bootstrap_build.py @@ -6,18 +6,22 @@ from pythonforandroid.util import load_source -class TestBootstrapBuild(unittest.TestCase): - def setUp(self): - os.environ["P4A_BUILD_IS_RUNNING_UNITTESTS"] = "1" +def load_bootstrap_build_module(): + os.environ["P4A_BUILD_IS_RUNNING_UNITTESTS"] = "1" - build_src = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "../pythonforandroid/bootstraps/common/build/build.py", - ) + build_src = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "../pythonforandroid/bootstraps/common/build/build.py", + ) - self.buildpy = load_source("buildpy", build_src) - self.buildpy.get_bootstrap_name = mock.Mock(return_value="sdl2") + buildpy = load_source("buildpy", build_src) + buildpy.get_bootstrap_name = mock.Mock(return_value="sdl2") + return buildpy + +class TestBootstrapBuild(unittest.TestCase): + def setUp(self): + self.buildpy = load_bootstrap_build_module() self.ap = self.buildpy.create_argument_parser() self.common_args = [ @@ -178,3 +182,50 @@ def test_sdl_orientation_hint_multiple(self): assert "LandscapeLeft" in sdl_orientation_hint assert "Portrait" in sdl_orientation_hint + + +class TestAndroidNumericVersion: + def setup_method(self): + self.buildpy = load_bootstrap_build_module() + + def test_generates_default_three_part_version_code(self): + assert self.buildpy.get_android_numeric_version("1.0.5", 24) == "102410005" + + def test_accepts_and_normalizes_explicit_numeric_version(self): + assert self.buildpy.validate_android_numeric_version("0000001") == "1" + assert self.buildpy.validate_android_numeric_version(2100000000) == "2100000000" + + @pytest.mark.parametrize("numeric_version", ["abc", "1.2", "", None]) + def test_rejects_non_integer_explicit_numeric_versions(self, numeric_version): + with pytest.raises(ValueError, match="--numeric-version.*decimal integer"): + self.buildpy.validate_android_numeric_version(numeric_version) + + @pytest.mark.parametrize("numeric_version", ["0", 0, "-1", -1]) + def test_rejects_non_positive_explicit_numeric_versions(self, numeric_version): + with pytest.raises(ValueError, match="--numeric-version.*greater than 0"): + self.buildpy.validate_android_numeric_version(numeric_version) + + @pytest.mark.parametrize("numeric_version", ["2100000001", 2100000001]) + def test_rejects_oversized_explicit_numeric_versions(self, numeric_version): + with pytest.raises( + ValueError, match="--numeric-version.*2100000000" + ): + self.buildpy.validate_android_numeric_version(numeric_version) + + def test_rejects_generated_overflow_and_mentions_version_name(self): + generated_version = self.buildpy.get_android_numeric_version("1.0.5.1", 24) + + with pytest.raises( + ValueError, + match="Generated Android versionCode .*--version '1\\.0\\.5\\.1'.*--numeric-version.*2100000000", + ): + self.buildpy.validate_android_numeric_version( + generated_version, generated_from_version="1.0.5.1" + ) + + def test_rejects_non_numeric_version_name_for_generation(self): + with pytest.raises( + ValueError, + match="Could not generate Android versionCode from --version '1\\.0\\.beta'.*versionName.*--numeric-version", + ): + self.buildpy.get_android_numeric_version("1.0.beta", 24)