File indexing completed on 2024-04-21 14:53:12
0001 #!/usr/bin/env python3 0002 # 0003 # SPDX-FileCopyrightText: 2018-2020 Aleix Pol Gonzalez <aleixpol@kde.org> 0004 # SPDX-FileCopyrightText: 2019-2020 Ben Cooksley <bcooksley@kde.org> 0005 # SPDX-FileCopyrightText: 2020 Volker Krause <vkrause@kde.org> 0006 # 0007 # SPDX-License-Identifier: GPL-2.0-or-later 0008 # 0009 # Generates fastlane metadata for Android apps from appstream files. 0010 # 0011 0012 import argparse 0013 import glob 0014 import io 0015 import os 0016 import re 0017 import requests 0018 import shutil 0019 import subprocess 0020 import sys 0021 import tempfile 0022 import xdg.DesktopEntry 0023 import xml.etree.ElementTree as ET 0024 import yaml 0025 import zipfile 0026 0027 # Constants used in this script 0028 # map KDE's translated language codes to those expected by Android 0029 # see https://f-droid.org/en/docs/Translation_and_Localization/ 0030 # F-Droid is more tolerant than the Play Store here, the latter rejects anything not exactly matching its known codes 0031 # Android does do the expected fallbacks, so the seemingly "too specific" mappings here are still working as expected 0032 # see https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples 0033 languageMap = { 0034 None: "en-US", 0035 "ca-valencia": None, # not supported by Android 0036 "cs": "cs-CZ", 0037 "de": "de-DE", 0038 "es": "es-ES", 0039 "eu": "eu-ES", 0040 "fi": "fi-FI", 0041 "fr": "fr-FR", 0042 "gl": "gl-ES", 0043 "ia": None, # not supported by Android 0044 "it": "it-IT", 0045 "ko": "ko-KR", 0046 "nl": "nl-NL", 0047 "pl": "pl-PL", 0048 "pt": "pt-PT", 0049 "ru": "ru-RU", 0050 "sr": "sr-Cyrl-RS", 0051 "sr@latin": "sr-Latn-RS", 0052 "sv": "sv-SE", 0053 'x-test': None 0054 } 0055 0056 # The subset of supported rich text tags in F-Droid and Google Play 0057 # - see https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/ for F-Droid 0058 # - Google Play doesn't support lists 0059 supportedRichTextTags = { 'b', 'u', 'i' } 0060 0061 # List all translated languages present in an Appstream XML file 0062 def listAllLanguages(root, langs): 0063 for elem in root: 0064 lang = elem.get('{http://www.w3.org/XML/1998/namespace}lang') 0065 if not lang in langs: 0066 langs.add(lang) 0067 listAllLanguages(elem, langs) 0068 0069 # Apply language fallback to a map of translations 0070 def applyLanguageFallback(data, allLanguages): 0071 for l in allLanguages: 0072 if not l in data or not data[l] or len(data[l]) == 0: 0073 data[l] = data[None] 0074 0075 # Android appdata.xml textual item parser 0076 # This function handles reading standard text entries within an Android appdata.xml file 0077 # In particular, it handles splitting out the various translations, and converts some HTML to something which F-Droid can make use of 0078 # We have to handle incomplete translations both on top-level and intermediate tags, 0079 # and fall back to the English default text where necessary. 0080 def readText(elem, found, allLanguages): 0081 # Determine the language this entry is in 0082 lang = elem.get('{http://www.w3.org/XML/1998/namespace}lang') 0083 0084 # Do we have any text for this language yet? 0085 # If not, get everything setup 0086 for l in allLanguages: 0087 if not l in found: 0088 found[l] = "" 0089 0090 # If there is text available, we'll want to extract it 0091 # Additionally, if this element has any children, make sure we read those as well 0092 if elem.tag in supportedRichTextTags: 0093 if (elem.text and elem.text.strip()) or lang: 0094 found[lang] += '<' + elem.tag + '>' 0095 else: 0096 for l in allLanguages: 0097 found[l] += '<' + elem.tag + '>' 0098 elif elem.tag == 'li': 0099 found[lang] += 'ยท ' 0100 0101 if elem.text and elem.text.strip(): 0102 found[lang] += elem.text 0103 0104 subOutput = {} 0105 for child in elem: 0106 if not child.get('{http://www.w3.org/XML/1998/namespace}lang') and len(subOutput) > 0: 0107 applyLanguageFallback(subOutput, allLanguages) 0108 for l in allLanguages: 0109 found[l] += subOutput[l] 0110 subOutput = {} 0111 readText(child, subOutput, allLanguages) 0112 if len(subOutput) > 0: 0113 applyLanguageFallback(subOutput, allLanguages) 0114 for l in allLanguages: 0115 found[l] += subOutput[l] 0116 0117 if elem.tag in supportedRichTextTags: 0118 if (elem.text and elem.text.strip()) or lang: 0119 found[lang] += '</' + elem.tag + '>' 0120 else: 0121 for l in allLanguages: 0122 found[l] += '</' + elem.tag + '>' 0123 0124 # Finally, if this element is a HTML Paragraph (p) or HTML List Item (li) make sure we add a new line for presentation purposes 0125 if elem.tag == 'li' or elem.tag == 'p': 0126 found[lang] += "\n" 0127 0128 0129 # Create the various Fastlane format files per the information we've previously extracted 0130 # These files are laid out following the Fastlane specification (links below) 0131 # https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots 0132 # https://docs.fastlane.tools/actions/supply/ 0133 def createFastlaneFile( applicationName, filenameToPopulate, fileContent ): 0134 # Go through each language and content pair we've been given 0135 for lang, text in fileContent.items(): 0136 # First, do we need to amend the language id, to turn the Android language ID into something more F-Droid/Fastlane friendly? 0137 languageCode = languageMap.get(lang, lang) 0138 if not languageCode: 0139 continue 0140 0141 # Next we need to determine the path to the directory we're going to be writing the data into 0142 repositoryBasePath = arguments.output 0143 path = os.path.join( repositoryBasePath, 'metadata', applicationName, languageCode ) 0144 0145 # Make sure the directory exists 0146 os.makedirs(path, exist_ok=True) 0147 0148 # Now write out file contents! 0149 with open(path + '/' + filenameToPopulate, 'w') as f: 0150 f.write(text.strip()) # trim whitespaces, to avoid spurious differences after a Google Play roundtrip 0151 0152 # Create the summary appname.yml file used by F-Droid to summarise this particular entry in the repository 0153 # see https://f-droid.org/en/docs/Build_Metadata_Reference/ 0154 def createYml(appname, data): 0155 # Prepare to retrieve the existing information 0156 info = {} 0157 0158 # Determine the path to the appname.yml file 0159 repositoryBasePath = arguments.output 0160 path = os.path.join( repositoryBasePath, 'metadata', appname + '.yml' ) 0161 0162 # Update the categories first 0163 # Now is also a good time to add 'KDE' to the list of categories as well 0164 if 'categories' in data: 0165 info['Categories'] = data['categories'][None] + ['KDE'] 0166 else: 0167 info['Categories'] = ['KDE'] 0168 0169 # Update the general summary as well 0170 info['Summary'] = data['summary'][None] 0171 0172 # Check to see if we have a Homepage... 0173 if 'url-homepage' in data: 0174 info['WebSite'] = data['url-homepage'][None] 0175 0176 # What about a bug tracker? 0177 if 'url-bugtracker' in data: 0178 info['IssueTracker'] = data['url-bugtracker'][None] 0179 0180 if 'project_license' in data: 0181 info["License"] = data['project_license'][None] 0182 0183 if 'source-repo' in data: 0184 info['SourceCode'] = data['source-repo'] 0185 0186 if 'url-donation' in data: 0187 info['Donate'] = data['url-donation'][None] 0188 else: 0189 info['Donate'] = 'https://kde.org/community/donations/' 0190 0191 # static data 0192 info['Translation'] = 'https://l10n.kde.org/' 0193 0194 # Finally, with our updates completed, we can save the updated appname.yml file back to disk 0195 with open(path, 'w') as output: 0196 yaml.dump(info, output, default_flow_style=False) 0197 0198 # Integrates locally existing image assets into the metadata 0199 def processLocalImages(applicationName, data): 0200 if not os.path.exists(os.path.join(arguments.source, 'fastlane')): 0201 return 0202 0203 outPath = os.path.abspath(arguments.output); 0204 oldcwd = os.getcwd() 0205 os.chdir(os.path.join(arguments.source, 'fastlane')) 0206 0207 imageFiles = glob.glob('metadata/**/*.png', recursive=True) 0208 imageFiles.extend(glob.glob('metadata/**/*.jpg', recursive=True)) 0209 for image in imageFiles: 0210 # noramlize single- vs multi-app layouts 0211 imageDestName = image.replace('metadata/android', 'metadata/' + applicationName) 0212 0213 # copy image 0214 os.makedirs(os.path.dirname(os.path.join(outPath, imageDestName)), exist_ok=True) 0215 shutil.copy(image, os.path.join(outPath, imageDestName)) 0216 0217 # if the source already contains screenshots, those override whatever we found in the appstream file 0218 if 'phoneScreenshots' in image: 0219 data['screenshots'] = {} 0220 0221 os.chdir(oldcwd) 0222 0223 # Attempt to find the application icon if we haven't gotten that explicitly from processLocalImages 0224 def findIcon(applicationName, iconBaseName): 0225 iconPath = os.path.join(arguments.output, 'metadata', applicationName, 'en-US', 'images', 'icon.png') 0226 if os.path.exists(iconPath): 0227 return 0228 0229 oldcwd = os.getcwd() 0230 os.chdir(arguments.source) 0231 0232 iconFiles = glob.glob(f"**/{iconBaseName}-playstore.png", recursive=True) 0233 for icon in iconFiles: 0234 os.makedirs(os.path.dirname(iconPath), exist_ok=True) 0235 shutil.copy(icon, iconPath) 0236 break 0237 0238 os.chdir(oldcwd) 0239 0240 # Download screenshots referenced in the appstream data 0241 # see https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/ 0242 def downloadScreenshots(applicationName, data): 0243 if not 'screenshots' in data: 0244 return 0245 0246 path = os.path.join(arguments.output, 'metadata', applicationName, 'en-US', 'images', 'phoneScreenshots') 0247 os.makedirs(path, exist_ok=True) 0248 0249 i = 1 # number screenshots starting at 1 rather than 0 to match what the fastlane tool does 0250 for screenshot in data['screenshots']: 0251 fileName = str(i) + '-' + screenshot[screenshot.rindex('/') + 1:] 0252 r = requests.get(screenshot) 0253 if r.status_code < 400: 0254 with open(os.path.join(path, fileName), 'wb') as f: 0255 f.write(r.content) 0256 i += 1 0257 0258 # Put all metadata for the given application name into an archive 0259 # We need this to easily transfer the entire metadata to the signing machine for integration 0260 # into the F-Droid nightly repository 0261 def createMetadataArchive(applicationName): 0262 srcPath = os.path.join(arguments.output, 'metadata') 0263 zipFileName = os.path.join(srcPath, 'fastlane-' + applicationName + '.zip') 0264 if os.path.exists(zipFileName): 0265 os.unlink(zipFileName) 0266 archive = zipfile.ZipFile(zipFileName, 'w') 0267 archive.write(os.path.join(srcPath, applicationName + '.yml'), applicationName + '.yml') 0268 0269 oldcwd = os.getcwd() 0270 os.chdir(srcPath) 0271 for file in glob.iglob(applicationName + '/**', recursive=True): 0272 archive.write(file, file) 0273 os.chdir(oldcwd) 0274 0275 # Generate metadata for the given appstream and desktop files 0276 def processAppstreamFile(appstreamFileName, desktopFileName, iconBaseName): 0277 # appstreamFileName has the form <id>.appdata.xml or <id>.metainfo.xml, so we 0278 # have to strip off two extensions 0279 applicationName = os.path.splitext(os.path.splitext(os.path.basename(appstreamFileName))[0])[0] 0280 0281 data = {} 0282 # Within this file we look at every entry, and where possible try to export it's content so we can use it later 0283 appstreamFile = open(appstreamFileName, "rb") 0284 root = ET.fromstring(appstreamFile.read()) 0285 0286 allLanguages = set() 0287 listAllLanguages(root, allLanguages) 0288 0289 for child in root: 0290 # Make sure we start with a blank slate for this entry 0291 output = {} 0292 0293 # Grab the name of this particular attribute we're looking at 0294 # Within the Fastlane specification, it is possible to have several items with the same name but as different types 0295 # We therefore include this within our extracted name for the attribute to differentiate them 0296 tag = child.tag 0297 if 'type' in child.attrib: 0298 tag += '-' + child.attrib['type'] 0299 0300 # Have we found some information already for this particular attribute? 0301 if tag in data: 0302 output = data[tag] 0303 0304 # Are we dealing with category information here? 0305 # If so, then we need to look into this items children to find out all the categories this APK belongs in 0306 if tag == 'categories': 0307 cats = [] 0308 for x in child: 0309 cats.append(x.text) 0310 output = { None: cats } 0311 0312 # screenshot links 0313 elif tag == 'screenshots': 0314 output = [] 0315 for screenshot in child: 0316 if screenshot.tag == 'screenshot': 0317 for image in screenshot: 0318 if image.tag == 'image': 0319 output.append(image.text) 0320 0321 # Otherwise this is just textual information we need to extract 0322 else: 0323 readText(child, output, allLanguages) 0324 0325 # Save the information we've gathered! 0326 data[tag] = output 0327 0328 applyLanguageFallback(data['name'], allLanguages) 0329 applyLanguageFallback(data['summary'], allLanguages) 0330 applyLanguageFallback(data['description'], allLanguages) 0331 0332 # Did we find any categories? 0333 # Sometimes we don't find any within the Fastlane information, but without categories the F-Droid store isn't of much use 0334 # In the event this happens, fallback to the *.desktop file for the application to see if it can provide any insight. 0335 if not 'categories' in data and desktopFileName: 0336 # Parse the XDG format *.desktop file, and extract the categories within it 0337 desktopFile = xdg.DesktopEntry.DesktopEntry(desktopFileName) 0338 data['categories'] = { None: desktopFile.getCategories() } 0339 0340 # Try to figure out the source repository 0341 if arguments.source and os.path.exists(os.path.join(arguments.source, '.git')): 0342 upstream_ref = subprocess.check_output(['git', 'rev-parse', '--symbolic-full-name', '@{u}'], cwd=arguments.source).decode('utf-8') 0343 remote = upstream_ref.split('/')[2] 0344 output = subprocess.check_output(['git', 'remote', 'get-url', remote], cwd=arguments.source).decode('utf-8') 0345 data['source-repo'] = output.strip() 0346 0347 # write meta data 0348 createFastlaneFile( applicationName, "title.txt", data['name'] ) 0349 createFastlaneFile( applicationName, "short_description.txt", data['summary'] ) 0350 createFastlaneFile( applicationName, "full_description.txt", data['description'] ) 0351 createYml(applicationName, data) 0352 0353 # cleanup old image files before collecting new ones 0354 imagePath = os.path.join(arguments.output, 'metadata', applicationName, 'en-US', 'images') 0355 shutil.rmtree(imagePath, ignore_errors=True) 0356 processLocalImages(applicationName, data) 0357 downloadScreenshots(applicationName, data) 0358 findIcon(applicationName, iconBaseName) 0359 0360 # put the result in an archive file for easier use by Jenkins 0361 createMetadataArchive(applicationName) 0362 0363 # scan source directory for manifests/metadata we can work with 0364 def scanSourceDir(): 0365 files = glob.iglob(arguments.source + "/**/AndroidManifest.xml*", recursive=True) 0366 for file in files: 0367 # third-party libraries might contain AndroidManifests which we are not interested in 0368 if "3rdparty" in file: 0369 continue 0370 0371 # find application id from manifest files 0372 root = ET.parse(file) 0373 appname = root.getroot().attrib['package'] 0374 is_app = False 0375 prefix = '{http://schemas.android.com/apk/res/android}' 0376 for md in root.findall("application/activity/meta-data"): 0377 if md.attrib[prefix + 'name'] == 'android.app.lib_name': 0378 is_app = True 0379 0380 if not appname or not is_app: 0381 continue 0382 0383 iconBaseName = None 0384 for elem in root.findall('application'): 0385 if prefix + 'icon' in elem.attrib: 0386 iconBaseName = elem.attrib[prefix + 'icon'].split('/')[-1] 0387 0388 # now that we have the app id, look for matching appdata/desktop files 0389 appdataFiles = glob.glob(arguments.source + "/**/" + appname + ".metainfo.xml", recursive=True) 0390 appdataFiles.extend(glob.glob(arguments.source + "/**/" + appname + ".appdata.xml", recursive=True)) 0391 appdataFile = None 0392 for f in appdataFiles: 0393 appdataFile = f 0394 break 0395 if not appdataFile: 0396 continue 0397 0398 desktopFiles = glob.iglob(arguments.source + "/**/" + appname + ".desktop", recursive=True) 0399 desktopFile = None 0400 for f in desktopFiles: 0401 desktopFile = f 0402 break 0403 0404 processAppstreamFile(appdataFile, desktopFile, iconBaseName) 0405 0406 0407 ### Script Commences 0408 0409 # Parse the command line arguments we've been given 0410 parser = argparse.ArgumentParser(description='Generate fastlane metadata for Android apps from appstream metadata') 0411 parser.add_argument('--appstream', type=str, required=False, help='Appstream file to extract metadata from') 0412 parser.add_argument('--desktop', type=str, required=False, help='Desktop file to extract additional metadata from') 0413 parser.add_argument('--source', type=str, required=False, help='Source directory to find metadata in') 0414 parser.add_argument('--output', type=str, required=True, help='Path to which the metadata output should be written to') 0415 arguments = parser.parse_args() 0416 0417 # ensure the output path exists 0418 os.makedirs(arguments.output, exist_ok=True) 0419 0420 # if we have an appstream file explicitly specified, let's use that one 0421 if arguments.appstream and os.path.exists(arguments.appstream): 0422 processAppstreamFile(arguments.appstream, arguments.desktop) 0423 sys.exit(0) 0424 0425 # else, look in the source dir for appstream/desktop files 0426 # this follows roughly what get-apk-args from binary factory does 0427 if arguments.source and os.path.exists(arguments.source): 0428 scanSourceDir() 0429 sys.exit(0) 0430 0431 # else: missing arguments 0432 print("Either one of --appstream or --source have to be provided!") 0433 sys.exit(1)