From 419fd5e8a3e212948b1b7b619f688be8fabf1bdf Mon Sep 17 00:00:00 2001 From: tshivaneshk Date: Sat, 26 Sep 2026 20:21:44 +0530 Subject: [PATCH] fix: handle Android SDK codenames in permission loading --- apkparser/permissions/__init__.py | 7 +- apkparser/permissions/ressources/__init__.py | 97 +++++++++++++++++--- tests/test_sdk_codename.py | 84 +++++++++++++++++ 3 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 tests/test_sdk_codename.py diff --git a/apkparser/permissions/__init__.py b/apkparser/permissions/__init__.py index f760515..6208ccd 100644 --- a/apkparser/permissions/__init__.py +++ b/apkparser/permissions/__init__.py @@ -1,6 +1,9 @@ from axml.axml import AXMLPrinter -from .ressources import load_api_specific_resource_module +from .ressources import ( + load_api_specific_resource_module, + _resolve_api_level, +) from apkparser.helper.logging import LOGGER # Dictionary of the different protection levels mapped to their corresponding attribute names as described in @@ -146,7 +149,7 @@ def get_uses_implied_permission_list(self) -> list[str]: def _update_permission_protection_level( self, protection_level, sdk_version ): - if not sdk_version or int(sdk_version) <= 15: + if not sdk_version or _resolve_api_level(sdk_version) <= 15: return protection_level.replace('Or', '|').lower() return protection_level diff --git a/apkparser/permissions/ressources/__init__.py b/apkparser/permissions/ressources/__init__.py index 7db654c..6347243 100644 --- a/apkparser/permissions/ressources/__init__.py +++ b/apkparser/permissions/ressources/__init__.py @@ -13,6 +13,82 @@ class APILevelNotFoundError(Exception): pass +# Mapping of Android SDK codenames to their official final API levels. +# Source: https://developer.android.com/tools/releases/platforms +# In Android development, preview builds use codenames (e.g. 'Q', 'Tiramisu') +# for targetSdkVersion and minSdkVersion before numeric API levels are finalized. +CODENAME_API_MAP: dict[str, int] = { + 'BASE': 1, + 'BASE_1_1': 2, + 'CUPCAKE': 3, + 'DONUT': 4, + 'ECLAIR': 5, + 'ECLAIR_0_1': 6, + 'ECLAIR_MR1': 7, + 'FROYO': 8, + 'GINGERBREAD': 9, + 'GINGERBREAD_MR1': 10, + 'HONEYCOMB': 11, + 'HONEYCOMB_MR1': 12, + 'HONEYCOMB_MR2': 13, + 'ICE_CREAM_SANDWICH': 14, + 'ICE_CREAM_SANDWICH_MR1': 15, + 'JELLY_BEAN': 16, + 'JELLY_BEAN_MR1': 17, + 'JELLY_BEAN_MR2': 18, + 'KITKAT': 19, + 'KITKAT_WATCH': 20, + 'LOLLIPOP': 21, + 'LOLLIPOP_MR1': 22, + 'M': 23, + 'N': 24, + 'N_MR1': 25, + 'O': 26, + 'O_MR1': 27, + 'P': 28, + 'Q': 29, + 'R': 30, + 'S': 31, + 'S_V2': 32, + 'TIRAMISU': 33, + 'T': 33, + 'UPSIDE_DOWN_CAKE': 34, + 'U': 34, + 'VANILLA_ICE_CREAM': 35, + 'V': 35, + 'BAKLAVA': 36, + 'B': 36, +} + + +def _resolve_api_level( + apilevel: Union[str, int, None], default_api: int = 16 +) -> int: + """ + Resolve an API level representation to a valid integer API level. + Supports integers, numeric strings, and known Android SDK codenames. + Falls back to default_api with a warning if the value is unknown or malformed. + """ + if apilevel is None: + return default_api + + if isinstance(apilevel, int): + return apilevel + + try: + return int(apilevel) + except (ValueError, TypeError): + clean_codename = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(apilevel).strip()) + clean_codename = clean_codename.upper().replace(' ', '_').replace('-', '_') + if clean_codename in CODENAME_API_MAP: + return CODENAME_API_MAP[clean_codename] + + LOGGER.warning( + f"Unknown or malformed API level '{apilevel}', falling back to default API level {default_api}" + ) + return default_api + + def load_api_specific_resource_module( resource_name: str, api: Union[str, int, None] = None, default_api: int = 16 ) -> dict: @@ -20,7 +96,7 @@ def load_api_specific_resource_module( Load the module from the JSON files and return a dict, which might be empty if the resource could not be loaded. - If no api version is given, the default one from the CONF dict is used. + If no api version is given, default_api is used. :param resource_name: Name of the resource to load :param api: API version @@ -40,17 +116,15 @@ def load_api_specific_resource_module( ) ) - if not api: - api = default_api - - ret = loader[resource_name](api) + resolved_api = _resolve_api_level(api, default_api=default_api) + ret = loader[resource_name](resolved_api) if ret == {}: # No API mapping found, return default LOGGER.warning( "API mapping for API level {} was not found! " "Returning default, which is API level {}".format( - api, CONF['DEFAULT_API'] + resolved_api, default_api ) ) ret = loader[resource_name](default_api) @@ -73,7 +147,7 @@ def load_permissions( the lower level is returned. For example, if 5,6,7,10 is available and 8 is requested, 7 is returned instead. - :param apilevel: integer value of the API level + :param apilevel: integer value or codename string of the API level :param permtype: either load permissions (`'permissions'`) or permission groups (`'groups'`) :return: a dictionary of {Permission Name: {Permission info} @@ -81,8 +155,7 @@ def load_permissions( if permtype not in ['permissions', 'groups']: raise ValueError("The type of permission list is not known.") - # Usually apilevel is supplied as string... - apilevel = int(apilevel) + apilevel = _resolve_api_level(apilevel) root = os.path.dirname(os.path.realpath(__file__)) permissions_file = os.path.join( @@ -137,11 +210,13 @@ def load_permission_mappings( ) -> dict[str, list[str]]: """ Load the API/Permission mapping for the requested API level. - If the requetsed level was not found, None is returned. + If the requested level was not found, None is returned. - :param apilevel: integer value of the API level, i.e. 24 for Android 7.0 + :param apilevel: integer value or codename string of the API level, i.e. 24 for Android 7.0 :return: a dictionary of {MethodSignature: [List of Permissions]} """ + apilevel = _resolve_api_level(apilevel) + root = os.path.dirname(os.path.realpath(__file__)) permissions_file = os.path.join( root, "api_permission_mappings", "permissions_{}.json".format(apilevel) diff --git a/tests/test_sdk_codename.py b/tests/test_sdk_codename.py new file mode 100644 index 0000000..aed8ca4 --- /dev/null +++ b/tests/test_sdk_codename.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +import unittest + +from apkparser.permissions.ressources import ( + load_permissions, + load_permission_mappings, + load_api_specific_resource_module, + _resolve_api_level, + CODENAME_API_MAP, +) + + +class SdkCodenameResolutionTest(unittest.TestCase): + def test_integer_api_level(self): + """Existing integer API levels must behave exactly as before.""" + self.assertEqual(_resolve_api_level(29), 29) + self.assertEqual(_resolve_api_level(33), 33) + self.assertEqual(_resolve_api_level(35), 35) + + perms_29 = load_permissions(29) + self.assertIsInstance(perms_29, dict) + self.assertIn('android.permission.INTERNET', perms_29) + + def test_numeric_string_api_level(self): + """Numeric strings representing API levels must continue working.""" + self.assertEqual(_resolve_api_level("29"), 29) + self.assertEqual(_resolve_api_level("33"), 33) + self.assertEqual(_resolve_api_level("35"), 35) + + self.assertEqual(load_permissions("29"), load_permissions(29)) + self.assertEqual( + load_api_specific_resource_module("aosp_permissions", "29"), + load_api_specific_resource_module("aosp_permissions", 29), + ) + + def test_known_sdk_codenames(self): + """Known Android SDK codenames must resolve to their correct integer API levels.""" + # Single-letter preview codenames + self.assertEqual(_resolve_api_level("N"), 24) + self.assertEqual(_resolve_api_level("P"), 28) + self.assertEqual(_resolve_api_level("Q"), 29) + self.assertEqual(_resolve_api_level("q"), 29) + self.assertEqual(_resolve_api_level("T"), 33) + self.assertEqual(_resolve_api_level("U"), 34) + self.assertEqual(_resolve_api_level("V"), 35) + self.assertEqual(_resolve_api_level("B"), 36) + + # Full codenames with case and format variations + self.assertEqual(_resolve_api_level("Tiramisu"), 33) + self.assertEqual(_resolve_api_level("TIRAMISU"), 33) + self.assertEqual(_resolve_api_level("UpsideDownCake"), 34) + self.assertEqual(_resolve_api_level("VanillaIceCream"), 35) + self.assertEqual(_resolve_api_level("Baklava"), 36) + self.assertEqual(_resolve_api_level("BAKLAVA"), 36) + + # Issue #3 core assertion: 'Q' loads API 29 permissions without ValueError + perms_q = load_permissions("Q") + self.assertIsInstance(perms_q, dict) + self.assertEqual(perms_q, load_permissions(29)) + self.assertIn('android.permission.INTERNET', perms_q) + + # load_api_specific_resource_module with codename + perms_module_q = load_api_specific_resource_module("aosp_permissions", "Q") + self.assertEqual(perms_module_q, load_api_specific_resource_module("aosp_permissions", 29)) + + def test_unknown_or_malformed_sdk_string(self): + """Unknown or malformed SDK strings must fall back safely and not raise ValueError.""" + # Must fall back to default_api (default: 16) + resolved = _resolve_api_level("UNKNOWN_FUTURE_CODENAME", default_api=16) + self.assertEqual(resolved, 16) + + # load_permissions should safely load fallback rather than raise ValueError + perms_unknown = load_permissions("UNKNOWN_FUTURE_CODENAME") + self.assertIsInstance(perms_unknown, dict) + self.assertEqual(perms_unknown, load_permissions(16)) + + def test_none_api_level(self): + """None value falls back to default_api.""" + self.assertEqual(_resolve_api_level(None, default_api=16), 16) + self.assertEqual(_resolve_api_level(None, default_api=21), 21) + + +if __name__ == '__main__': + unittest.main()