diff --git a/doc/source/buildoptions.rst b/doc/source/buildoptions.rst index 167f4fd71..ac1f3040b 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 e2aacc7ac..2eb2d555f 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 ff5f7dcac..e78c2e577 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)