From 267907d329962026bc0c4d726fadbb7df69c6346 Mon Sep 17 00:00:00 2001 From: rdb Date: Tue, 7 Dec 2021 23:56:19 +0100 Subject: [PATCH] dist: Support adding icons to Android applications [skip ci] --- direct/src/dist/_android.py | 45 +++++++++++++++++++++++++++-------- direct/src/dist/commands.py | 25 +++++++++++++++++++ direct/src/dist/icon.py | 35 +++++++++++++++++++++++++++ direct/src/dist/installers.py | 34 ++++++++++++++++++++++---- 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/direct/src/dist/_android.py b/direct/src/dist/_android.py index 051e4f08aa..11a00cd38d 100644 --- a/direct/src/dist/_android.py +++ b/direct/src/dist/_android.py @@ -12,13 +12,13 @@ AbiAlias = Abi.AbiAlias def str_resource(id): - def compile(attrib): + def compile(attrib, manifest): attrib.resource_id = id return compile def int_resource(id): - def compile(attrib): + def compile(attrib, manifest): attrib.resource_id = id if attrib.value.startswith('0x') or attrib.value.startswith('0X'): attrib.compiled_item.prim.int_hexadecimal_value = int(attrib.value, 16) @@ -28,7 +28,7 @@ def int_resource(id): def bool_resource(id): - def compile(attrib): + def compile(attrib, manifest): attrib.resource_id = id attrib.compiled_item.prim.boolean_value = { 'true': True, '1': True, 'false': False, '0': False @@ -37,14 +37,14 @@ def bool_resource(id): def enum_resource(id, /, *values): - def compile(attrib): + def compile(attrib, manifest): attrib.resource_id = id attrib.compiled_item.prim.int_decimal_value = values.index(attrib.value) return compile def flag_resource(id, /, **values): - def compile(attrib): + def compile(attrib, manifest): attrib.resource_id = id bitmask = 0 flags = attrib.value.split('|') @@ -54,14 +54,17 @@ def flag_resource(id, /, **values): return compile -def ref_resource(id, type): - def compile(attrib): +def ref_resource(id): + def compile(attrib, manifest): assert attrib.value[0] == '@' ref_type, ref_name = attrib.value[1:].split('/') attrib.resource_id = id + attrib.compiled_item.ref.name = ref_type + '/' + ref_name + if ref_type == 'android:style': attrib.compiled_item.ref.id = ANDROID_STYLES[ref_name] - attrib.compiled_item.ref.name = ref_type + '/' + ref_name + elif ':' not in ref_type: + attrib.compiled_item.ref.id = manifest.register_resource(ref_type, ref_name) else: print(f'Warning: unhandled AndroidManifest.xml reference "{attrib.value}"') return compile @@ -175,6 +178,7 @@ ANDROID_ATTRIBUTES = { 'glEsVersion': int_resource(0x1010281), 'hasCode': bool_resource(0x101000c), 'host': str_resource(0x1010028), + 'icon': ref_resource(0x1010002), 'immersive': bool_resource(0x10102c0), 'installLocation': enum_resource(0x10102b7, "auto", "internalOnly", "preferExternal"), 'isGame': bool_resource(0x010103f4), @@ -189,10 +193,11 @@ ANDROID_ATTRIBUTES = { 'required': bool_resource(0x101028e), 'scheme': str_resource(0x1010027), 'stateNotNeeded': bool_resource(0x1010016), + 'supportsRtl': bool_resource(0x010103af), 'supportsUploading': bool_resource(0x101029b), 'targetSandboxVersion': int_resource(0x101054c), 'targetSdkVersion': int_resource(0x1010270), - 'theme': ref_resource(0x01010000, 'android:style'), + 'theme': ref_resource(0x01010000), 'value': str_resource(0x1010024), 'versionCode': int_resource(0x101021b), 'versionName': str_resource(0x101021c), @@ -204,6 +209,8 @@ class AndroidManifest: super().__init__() self._stack = [] self.root = XmlNode() + self.resource_types = [] + self.resources = {} def parse_xml(self, data): parser = ET.XMLParser(target=self) @@ -241,10 +248,28 @@ class AndroidManifest: attrib.name = key if res_compile: - res_compile(attrib) + res_compile(attrib, self) def end(self, tag): self._stack.pop() + def register_resource(self, type, name): + if type not in self.resource_types: + self.resource_types.append(type) + type_id = len(self.resource_types) + self.resources[type] = [] + else: + type_id = self.resource_types.index(type) + 1 + + resources = self.resources[type] + if name in resources: + entry_id = resources.index(name) + else: + entry_id = len(resources) + resources.append(name) + + id = (0x7f << 24) | (type_id << 16) | (entry_id) + return id + def dumps(self): return self.root.SerializeToString() diff --git a/direct/src/dist/commands.py b/direct/src/dist/commands.py index f52438f5c1..935ffc2147 100644 --- a/direct/src/dist/commands.py +++ b/direct/src/dist/commands.py @@ -608,6 +608,10 @@ class build_apps(setuptools.Command): application.set('android:debuggable', ('false', 'true')[self.android_debuggable]) application.set('android:extractNativeLibs', 'true') + app_icon = self.icon_objects.get('*', self.icon_objects.get(self.macos_main_app)) + if app_icon: + application.set('android:icon', '@mipmap/ic_launcher') + for appname in self.gui_apps: activity = ET.SubElement(application, 'activity') activity.set('android:name', 'org.panda3d.android.PandaActivity') @@ -616,6 +620,10 @@ class build_apps(setuptools.Command): activity.set('android:configChanges', 'orientation|keyboardHidden') activity.set('android:launchMode', 'singleInstance') + act_icon = self.icon_objects.get(appname) + if act_icon and act_icon is not app_icon: + activity.set('android:icon', '@mipmap/ic_' + appname) + meta_data = ET.SubElement(activity, 'meta-data') meta_data.set('android:name', 'android.app.lib_name') meta_data.set('android:value', appname) @@ -1187,6 +1195,23 @@ class build_apps(setuptools.Command): # Generate an AndroidManifest.xml self.generate_android_manifest(os.path.join(builddir, 'AndroidManifest.xml')) + # Write out the icons to the res directory. + for appname, icon in self.icon_objects.items(): + if appname == '*' or (appname == self.macos_main_app and '*' not in self.icon_objects): + # Conventional name for icon on Android. + basename = 'ic_launcher.png' + else: + basename = f'ic_{appname}.png' + + res_dir = os.path.join(builddir, 'res') + icon.writeSize(48, os.path.join(res_dir, 'mipmap-mdpi-v4', basename)) + icon.writeSize(72, os.path.join(res_dir, 'mipmap-hdpi-v4', basename)) + icon.writeSize(96, os.path.join(res_dir, 'mipmap-xhdpi-v4', basename)) + icon.writeSize(144, os.path.join(res_dir, 'mipmap-xxhdpi-v4', basename)) + + if icon.getLargestSize() >= 192: + icon.writeSize(192, os.path.join(res_dir, 'mipmap-xxxhdpi-v4', basename)) + # Bundle into an .app on macOS if self.macos_main_app and 'macosx' in platform: self.bundle_macos_app(builddir) diff --git a/direct/src/dist/icon.py b/direct/src/dist/icon.py index 9bfe36092b..a4f4b9abac 100644 --- a/direct/src/dist/icon.py +++ b/direct/src/dist/icon.py @@ -32,6 +32,9 @@ class Icon: return True + def getLargestSize(self): + return max(self.images.keys()) + def generateMissingImages(self): """ Generates image sizes that should be present but aren't by scaling from the next higher size. """ @@ -269,3 +272,35 @@ class Icon: icns.close() return True + + def writeSize(self, required_size, fn): + if not isinstance(fn, Filename): + fn = Filename.fromOsSpecific(fn) + fn.setBinary() + fn.makeDir() + + if required_size in self.images: + image = self.images[required_size] + else: + # Find the next size up. + sizes = sorted(self.images.keys()) + if required_size * 2 in sizes: + from_size = required_size * 2 + else: + from_size = 0 + for from_size in sizes: + if from_size > required_size: + break + + if from_size > required_size: + Icon.notify.warning("Generating %dx%d icon by scaling down %dx%d image" % (required_size, required_size, from_size, from_size)) + else: + Icon.notify.warning("Generating %dx%d icon by scaling up %dx%d image" % (required_size, required_size, from_size, from_size)) + + from_image = self.images[from_size] + image = PNMImage(required_size, required_size) + image.setColorType(from_image.getColorType()) + image.quickFilterFrom(from_image) + + if not image.write(fn): + Icon.notify.error("Failed to write %dx%d to %s" % (required_size, required_size, fn)) diff --git a/direct/src/dist/installers.py b/direct/src/dist/installers.py index 15dcb2e267..2313cd573b 100644 --- a/direct/src/dist/installers.py +++ b/direct/src/dist/installers.py @@ -212,6 +212,11 @@ def create_aab(command, basename, build_dir): bundle_fn = p3d.Filename.from_os_specific(command.dist_dir) / (basename + '.aab') build_dir_fn = p3d.Filename.from_os_specific(build_dir) + # Convert the AndroidManifest.xml file to a protobuf-encoded version of it. + axml = AndroidManifest() + with open(os.path.join(build_dir, 'AndroidManifest.xml'), 'rb') as fh: + axml.parse_xml(fh.read()) + # We use our own zip implementation, which can create the correct # alignment needed by Android automatically. bundle = p3d.ZipArchive() @@ -227,6 +232,31 @@ def create_aab(command, basename, build_dir): bundle.add_subfile('BundleConfig.pb', p3d.StringStream(config.SerializeToString()), 9) resources = ResourceTable() + package = resources.package.add() + package.package_id.id = 0x7f + for attrib in axml.root.element.attribute: + if attrib.name == 'package': + package.package_name = attrib.value + + # Were there any icons referenced in the AndroidManifest.xml? + for type_i, type_name in enumerate(axml.resource_types): + res_type = package.type.add() + res_type.name = type_name + res_type.type_id.id = type_i + 1 + + for entry_id, res_name in enumerate(axml.resources[type_name]): + entry = res_type.entry.add() + entry.entry_id.id = entry_id + entry.name = res_name + + for density, tag in (160, 'mdpi'), (240, 'hdpi'), (320, 'xhdpi'), (480, 'xxhdpi'), (640, 'xxxhdpi'): + path = f'res/mipmap-{tag}-v4/{res_name}.png' + if (build_dir_fn / path).exists(): + bundle.add_subfile('base/' + path, build_dir_fn / path, 0) + config_value = entry.config_value.add() + config_value.config.density = density + config_value.value.item.file.path = path + bundle.add_subfile('base/resources.pb', p3d.StringStream(resources.SerializeToString()), 9) native = NativeLibraries() @@ -236,10 +266,6 @@ def create_aab(command, basename, build_dir): native_dir.targeting.abi.alias = getattr(AbiAlias, abi.upper().replace('-', '_')) bundle.add_subfile('base/native.pb', p3d.StringStream(native.SerializeToString()), 9) - # Convert the AndroidManifest.xml file to a protobuf-encoded version of it. - axml = AndroidManifest() - with open(os.path.join(build_dir, 'AndroidManifest.xml'), 'rb') as fh: - axml.parse_xml(fh.read()) bundle.add_subfile('base/manifest/AndroidManifest.xml', p3d.StringStream(axml.dumps()), 9) # Add the classes.dex.