Skip to content

Commit 7b9121d

Browse files
authored
Merge pull request kivy#3332 from AndreMiras/feature/android-version-code-guardrails
🦺 Guard Android versionCode values
2 parents 98a1063 + f72e940 commit 7b9121d

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)