Browse Source

dist: Support adding icons to Android applications

[skip ci]
rdb 4 years ago
parent
commit
267907d329
4 changed files with 125 additions and 14 deletions
  1. 35 10
      direct/src/dist/_android.py
  2. 25 0
      direct/src/dist/commands.py
  3. 35 0
      direct/src/dist/icon.py
  4. 30 4
      direct/src/dist/installers.py

+ 35 - 10
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()

+ 25 - 0
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)

+ 35 - 0
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))

+ 30 - 4
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.