Skip to content

Commit f72e940

Browse files
committed
🦺 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
1 parent 98a1063 commit f72e940

3 files changed

Lines changed: 162 additions & 31 deletions

File tree

doc/source/buildoptions.rst

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ options (this list may not be exhaustive):
5656
- ``--private``: The directory containing your project files.
5757
- ``--package``: The Java package name for your project. e.g. ``org.example.yourapp``.
5858
- ``--name``: The app name.
59-
- ``--version``: The version number.
59+
- ``--version``: The display version shown to users. On Android this is
60+
rendered as ``versionName``.
61+
- ``--numeric-version``: The Android ``versionCode`` used for update ordering.
62+
This must be a positive integer no greater than ``2100000000``. If omitted,
63+
python-for-android computes it from ``--version``. If the computed value is
64+
too large, keep the display version in ``--version`` and set a valid
65+
``--numeric-version``. See Android's
66+
`versionCode documentation <https://developer.android.com/tools/publishing/versioning>`__.
6067
- ``--orientation``: The orientations that the app will display in.
6168
(Available options are ``portrait``, ``landscape``, ``portrait-reverse``, ``landscape-reverse``).
6269
Since Android ignores ``android:screenOrientation`` when in multi-window mode
@@ -145,7 +152,14 @@ ready.
145152
- ``--private``: The directory containing your project files.
146153
- ``--package``: The Java package name for your project. e.g. ``org.example.yourapp``.
147154
- ``--name``: The app name.
148-
- ``--version``: The version number.
155+
- ``--version``: The display version shown to users. On Android this is
156+
rendered as ``versionName``.
157+
- ``--numeric-version``: The Android ``versionCode`` used for update ordering.
158+
This must be a positive integer no greater than ``2100000000``. If omitted,
159+
python-for-android computes it from ``--version``. If the computed value is
160+
too large, keep the display version in ``--version`` and set a valid
161+
``--numeric-version``. See Android's
162+
`versionCode documentation <https://developer.android.com/tools/publishing/versioning>`__.
149163
- ``--orientation``: The orientations that the app will display in.
150164
(Available options are ``portrait``, ``landscape``, ``portrait-reverse``, ``landscape-reverse``).
151165
Since Android ignores ``android:screenOrientation`` when in multi-window mode

pythonforandroid/bootstraps/common/build/build.py

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,73 @@ def get_bootstrap_name():
9393

9494
DEFAULT_PYTHON_ACTIVITY_JAVA_CLASS = 'org.kivy.android.PythonActivity'
9595
DEFAULT_PYTHON_SERVICE_JAVA_CLASS = 'org.kivy.android.PythonService'
96+
# Google Play's documented maximum Android versionCode.
97+
# https://developer.android.com/tools/publishing/versioning
98+
MAX_ANDROID_VERSION_CODE = 2100000000
99+
100+
101+
def get_android_numeric_version(version, min_sdk_version):
102+
"""
103+
Generate the default Android versionCode value from --version.
104+
105+
The format is (10 + minsdk + app_version). Older versioning was
106+
(arch + minsdk + app_version), with arch expressed with a single digit
107+
from 6 to 9. Since multi-arch support, this uses 10.
108+
"""
109+
version_code = 0
110+
try:
111+
for part in version.split('.'):
112+
version_code *= 100
113+
version_code += int(part)
114+
except ValueError as exc:
115+
raise ValueError(
116+
"Could not generate Android versionCode from --version "
117+
"{!r}. --version is Android versionName; when it is not numeric "
118+
"dot-separated text, set --numeric-version to a positive Android "
119+
"versionCode integer no greater than {}.".format(
120+
version, MAX_ANDROID_VERSION_CODE
121+
)
122+
) from exc
123+
return "{}{}{}".format("10", min_sdk_version, version_code)
124+
125+
126+
def validate_android_numeric_version(numeric_version, *, generated_from_version=None):
127+
try:
128+
normalized_version = int(numeric_version)
129+
except (TypeError, ValueError) as exc:
130+
raise ValueError(
131+
"--numeric-version must be a decimal integer Android versionCode "
132+
"greater than 0 and no greater than {}; got {!r}.".format(
133+
MAX_ANDROID_VERSION_CODE, numeric_version
134+
)
135+
) from exc
136+
137+
if normalized_version <= 0:
138+
raise ValueError(
139+
"--numeric-version must be a positive Android versionCode "
140+
"greater than 0; got {!r}.".format(numeric_version)
141+
)
142+
143+
if normalized_version > MAX_ANDROID_VERSION_CODE:
144+
if generated_from_version is not None:
145+
raise ValueError(
146+
"Generated Android versionCode {} from --version {!r}, "
147+
"which exceeds the maximum {}. --version is Android "
148+
"versionName; keep this display version by setting "
149+
"--numeric-version to a positive Android versionCode no "
150+
"greater than {}.".format(
151+
normalized_version,
152+
generated_from_version,
153+
MAX_ANDROID_VERSION_CODE,
154+
MAX_ANDROID_VERSION_CODE,
155+
)
156+
)
157+
raise ValueError(
158+
"--numeric-version is Android versionCode and must not exceed "
159+
"{}; got {!r}.".format(MAX_ANDROID_VERSION_CODE, numeric_version)
160+
)
161+
162+
return str(normalized_version)
96163

97164

98165
def render(template, dest, **kwargs):
@@ -420,19 +487,17 @@ def make_package(args):
420487
versioned_name = (args.name.replace(' ', '').replace('\'', '') +
421488
'-' + args.version)
422489

423-
version_code = 0
424-
if not args.numeric_version:
425-
"""
426-
Set version code in format (10 + minsdk + app_version)
427-
Historically versioning was (arch + minsdk + app_version),
428-
with arch expressed with a single digit from 6 to 9.
429-
Since the multi-arch support, has been changed to 10.
430-
"""
431-
min_sdk = args.min_sdk_version
432-
for i in args.version.split('.'):
433-
version_code *= 100
434-
version_code += int(i)
435-
args.numeric_version = "{}{}{}".format("10", min_sdk, version_code)
490+
generated_from_version = None
491+
if args.numeric_version is None:
492+
generated_from_version = args.version
493+
args.numeric_version = get_android_numeric_version(
494+
args.version,
495+
args.min_sdk_version,
496+
)
497+
args.numeric_version = validate_android_numeric_version(
498+
args.numeric_version,
499+
generated_from_version=generated_from_version,
500+
)
436501

437502
if args.intent_filters:
438503
with open(args.intent_filters) as fd:
@@ -793,14 +858,15 @@ def create_argument_parser():
793858
help=('The human-readable name of the project.'),
794859
required=True)
795860
ap.add_argument('--numeric-version', dest='numeric_version',
796-
help=('The numeric version number of the project. If not '
797-
'given, this is automatically computed from the '
798-
'version.'))
861+
help=('The Android versionCode of the project. This must '
862+
'be a positive decimal integer no greater than '
863+
'{}. If not given, it is automatically computed '
864+
'from --version.').format(MAX_ANDROID_VERSION_CODE))
799865
ap.add_argument('--version', dest='version',
800-
help=('The version number of the project. This should '
801-
'consist of numbers and dots, and should have the '
802-
'same number of groups of numbers as previous '
803-
'versions.'),
866+
help=('The Android versionName of the project, shown to '
867+
'users as the display version. Use '
868+
'--numeric-version to control Android versionCode '
869+
'and update ordering.'),
804870
required=True)
805871
if is_sdl_bootstrap():
806872
ap.add_argument('--launcher', dest='launcher', action='store_true',

tests/test_bootstrap_build.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@
66
from pythonforandroid.util import load_source
77

88

9-
class TestBootstrapBuild(unittest.TestCase):
10-
def setUp(self):
11-
os.environ["P4A_BUILD_IS_RUNNING_UNITTESTS"] = "1"
9+
def load_bootstrap_build_module():
10+
os.environ["P4A_BUILD_IS_RUNNING_UNITTESTS"] = "1"
1211

13-
build_src = os.path.join(
14-
os.path.dirname(os.path.abspath(__file__)),
15-
"../pythonforandroid/bootstraps/common/build/build.py",
16-
)
12+
build_src = os.path.join(
13+
os.path.dirname(os.path.abspath(__file__)),
14+
"../pythonforandroid/bootstraps/common/build/build.py",
15+
)
1716

18-
self.buildpy = load_source("buildpy", build_src)
19-
self.buildpy.get_bootstrap_name = mock.Mock(return_value="sdl2")
17+
buildpy = load_source("buildpy", build_src)
18+
buildpy.get_bootstrap_name = mock.Mock(return_value="sdl2")
19+
return buildpy
2020

21+
22+
class TestBootstrapBuild(unittest.TestCase):
23+
def setUp(self):
24+
self.buildpy = load_bootstrap_build_module()
2125
self.ap = self.buildpy.create_argument_parser()
2226

2327
self.common_args = [
@@ -178,3 +182,50 @@ def test_sdl_orientation_hint_multiple(self):
178182

179183
assert "LandscapeLeft" in sdl_orientation_hint
180184
assert "Portrait" in sdl_orientation_hint
185+
186+
187+
class TestAndroidNumericVersion:
188+
def setup_method(self):
189+
self.buildpy = load_bootstrap_build_module()
190+
191+
def test_generates_default_three_part_version_code(self):
192+
assert self.buildpy.get_android_numeric_version("1.0.5", 24) == "102410005"
193+
194+
def test_accepts_and_normalizes_explicit_numeric_version(self):
195+
assert self.buildpy.validate_android_numeric_version("0000001") == "1"
196+
assert self.buildpy.validate_android_numeric_version(2100000000) == "2100000000"
197+
198+
@pytest.mark.parametrize("numeric_version", ["abc", "1.2", "", None])
199+
def test_rejects_non_integer_explicit_numeric_versions(self, numeric_version):
200+
with pytest.raises(ValueError, match="--numeric-version.*decimal integer"):
201+
self.buildpy.validate_android_numeric_version(numeric_version)
202+
203+
@pytest.mark.parametrize("numeric_version", ["0", 0, "-1", -1])
204+
def test_rejects_non_positive_explicit_numeric_versions(self, numeric_version):
205+
with pytest.raises(ValueError, match="--numeric-version.*greater than 0"):
206+
self.buildpy.validate_android_numeric_version(numeric_version)
207+
208+
@pytest.mark.parametrize("numeric_version", ["2100000001", 2100000001])
209+
def test_rejects_oversized_explicit_numeric_versions(self, numeric_version):
210+
with pytest.raises(
211+
ValueError, match="--numeric-version.*2100000000"
212+
):
213+
self.buildpy.validate_android_numeric_version(numeric_version)
214+
215+
def test_rejects_generated_overflow_and_mentions_version_name(self):
216+
generated_version = self.buildpy.get_android_numeric_version("1.0.5.1", 24)
217+
218+
with pytest.raises(
219+
ValueError,
220+
match="Generated Android versionCode .*--version '1\\.0\\.5\\.1'.*--numeric-version.*2100000000",
221+
):
222+
self.buildpy.validate_android_numeric_version(
223+
generated_version, generated_from_version="1.0.5.1"
224+
)
225+
226+
def test_rejects_non_numeric_version_name_for_generation(self):
227+
with pytest.raises(
228+
ValueError,
229+
match="Could not generate Android versionCode from --version '1\\.0\\.beta'.*versionName.*--numeric-version",
230+
):
231+
self.buildpy.get_android_numeric_version("1.0.beta", 24)

0 commit comments

Comments
 (0)