3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2016, Blue Jay Wireless
5 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
6 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
7 # Copyright (C) 2013-2014, Daniel Martà <mvdan@mvdan.cc>
9 # This program is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU Affero General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU Affero General Public License for more details.
19 # You should have received a copy of the GNU Affero General Public License
20 # along with this program. If not, see <http://www.gnu.org/licenses/>.
32 from datetime import datetime
33 from argparse import ArgumentParser
36 from binascii import hexlify
38 from PIL import Image, PngImagePlugin
44 from . import metadata
45 from .common import SdkToolsPopen
46 from .exception import BuildException, FDroidException
50 # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
51 UNSET_VERSION_CODE = -0x100000000
53 APK_NAME_PAT = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
54 APK_VERCODE_PAT = re.compile(".*versionCode='([0-9]*)'.*")
55 APK_VERNAME_PAT = re.compile(".*versionName='([^']*)'.*")
56 APK_LABEL_ICON_PAT = re.compile(".*\s+label='(.*)'\s+icon='(.*)'")
57 APK_SDK_VERSION_PAT = re.compile(".*'([0-9]*)'.*")
58 APK_PERMISSION_PAT = \
59 re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
60 APK_FEATURE_PAT = re.compile(".*name='([^']*)'.*")
62 screen_densities = ['65534', '640', '480', '320', '240', '160', '120']
63 # resolutions must end with 'dpi'
64 screen_resolutions = {
76 all_screen_densities = ['0'] + screen_densities
78 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
79 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
81 ALLOWED_EXTENSIONS = ('png', 'jpg', 'jpeg')
82 GRAPHIC_NAMES = ('featureGraphic', 'icon', 'promoGraphic', 'tvBanner')
83 SCREENSHOT_DIRS = ('phoneScreenshots', 'sevenInchScreenshots',
84 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots')
86 BLANK_PNG_INFO = PngImagePlugin.PngInfo()
89 def dpi_to_px(density):
90 return (int(density) * 48) / 160
94 return (int(px) * 160) / 48
97 def get_icon_dir(repodir, density):
98 if density == '0' or density == '65534':
99 return os.path.join(repodir, "icons")
101 return os.path.join(repodir, "icons-%s" % density)
104 def get_icon_dirs(repodir):
105 for density in screen_densities:
106 yield get_icon_dir(repodir, density)
109 def get_all_icon_dirs(repodir):
110 for density in all_screen_densities:
111 yield get_icon_dir(repodir, density)
114 def update_wiki(apps, sortedids, apks):
117 :param apps: fully populated list of all applications
118 :param apks: all apks, except...
120 logging.info("Updating wiki")
122 wikiredircat = 'App Redirects'
124 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
125 path=config['wiki_path'])
126 site.login(config['wiki_user'], config['wiki_password'])
128 generated_redirects = {}
130 for appid in sortedids:
131 app = metadata.App(apps[appid])
135 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
137 for af in sorted(app.AntiFeatures):
138 wikidata += '{{AntiFeature|' + af + '}}\n'
143 wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|changelog=%s|donate=%s|flattr=%s|liberapay=%s|bitcoin=%s|litecoin=%s|license=%s|root=%s|author=%s|email=%s}}\n' % (
146 app.added.strftime('%Y-%m-%d') if app.added else '',
147 app.lastUpdated.strftime('%Y-%m-%d') if app.lastUpdated else '',
163 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
165 wikidata += app.Summary
166 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
168 wikidata += "=Description=\n"
169 wikidata += metadata.description_wiki(app.Description) + "\n"
171 wikidata += "=Maintainer Notes=\n"
172 if app.MaintainerNotes:
173 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
174 wikidata += "\nMetadata: [https://gitlab.com/fdroid/fdroiddata/blob/master/metadata/{0}.txt current] [https://gitlab.com/fdroid/fdroiddata/commits/master/metadata/{0}.txt history]\n".format(appid)
176 # Get a list of all packages for this application...
178 gotcurrentver = False
182 if apk['packageName'] == appid:
183 if str(apk['versionCode']) == app.CurrentVersionCode:
186 # Include ones we can't build, as a special case...
187 for build in app.builds:
189 if build.versionCode == app.CurrentVersionCode:
191 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
192 apklist.append({'versionCode': int(build.versionCode),
193 'versionName': build.versionName,
194 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
199 if apk['versionCode'] == int(build.versionCode):
204 apklist.append({'versionCode': int(build.versionCode),
205 'versionName': build.versionName,
206 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
208 if app.CurrentVersionCode == '0':
210 # Sort with most recent first...
211 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
213 wikidata += "=Versions=\n"
214 if len(apklist) == 0:
215 wikidata += "We currently have no versions of this app available."
216 elif not gotcurrentver:
217 wikidata += "We don't have the current version of this app."
219 wikidata += "We have the current version of this app."
220 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
221 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
222 if len(app.NoSourceSince) > 0:
223 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
224 if len(app.CurrentVersion) > 0:
225 wikidata += "The current (recommended) version is " + app.CurrentVersion
226 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
229 wikidata += "==" + apk['versionName'] + "==\n"
231 if 'buildproblem' in apk:
232 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
235 wikidata += "This version is built and signed by "
237 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
239 wikidata += "the original developer.\n\n"
240 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
242 wikidata += '\n[[Category:' + wikicat + ']]\n'
243 if len(app.NoSourceSince) > 0:
244 wikidata += '\n[[Category:Apps missing source code]]\n'
245 if validapks == 0 and not app.Disabled:
246 wikidata += '\n[[Category:Apps with no packages]]\n'
247 if cantupdate and not app.Disabled:
248 wikidata += "\n[[Category:Apps we cannot update]]\n"
249 if buildfails and not app.Disabled:
250 wikidata += "\n[[Category:Apps with failing builds]]\n"
251 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
252 wikidata += '\n[[Category:Apps to Update]]\n'
254 wikidata += '\n[[Category:Apps that are disabled]]\n'
255 if app.UpdateCheckMode == 'None' and not app.Disabled:
256 wikidata += '\n[[Category:Apps with no update check]]\n'
257 for appcat in app.Categories:
258 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
260 # We can't have underscores in the page name, even if they're in
261 # the package ID, because MediaWiki messes with them...
262 pagename = appid.replace('_', ' ')
264 # Drop a trailing newline, because mediawiki is going to drop it anyway
265 # and it we don't we'll think the page has changed when it hasn't...
266 if wikidata.endswith('\n'):
267 wikidata = wikidata[:-1]
269 generated_pages[pagename] = wikidata
271 # Make a redirect from the name to the ID too, unless there's
272 # already an existing page with the name and it isn't a redirect.
274 apppagename = app.Name.replace('_', ' ')
275 apppagename = apppagename.replace('{', '')
276 apppagename = apppagename.replace('}', ' ')
277 apppagename = apppagename.replace(':', ' ')
278 apppagename = apppagename.replace('[', ' ')
279 apppagename = apppagename.replace(']', ' ')
280 # Drop double spaces caused mostly by replacing ':' above
281 apppagename = apppagename.replace(' ', ' ')
282 for expagename in site.allpages(prefix=apppagename,
283 filterredir='nonredirects',
285 if expagename == apppagename:
287 # Another reason not to make the redirect page is if the app name
288 # is the same as it's ID, because that will overwrite the real page
289 # with an redirect to itself! (Although it seems like an odd
290 # scenario this happens a lot, e.g. where there is metadata but no
291 # builds or binaries to extract a name from.
292 if apppagename == pagename:
295 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
297 for tcat, genp in [(wikicat, generated_pages),
298 (wikiredircat, generated_redirects)]:
299 catpages = site.Pages['Category:' + tcat]
301 for page in catpages:
302 existingpages.append(page.name)
303 if page.name in genp:
304 pagetxt = page.edit()
305 if pagetxt != genp[page.name]:
306 logging.debug("Updating modified page " + page.name)
307 page.save(genp[page.name], summary='Auto-updated')
309 logging.debug("Page " + page.name + " is unchanged")
311 logging.warn("Deleting page " + page.name)
312 page.delete('No longer published')
313 for pagename, text in genp.items():
314 logging.debug("Checking " + pagename)
315 if pagename not in existingpages:
316 logging.debug("Creating page " + pagename)
318 newpage = site.Pages[pagename]
319 newpage.save(text, summary='Auto-created')
320 except Exception as e:
321 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
323 # Purge server cache to ensure counts are up to date
324 site.Pages['Repository Maintenance'].purge()
326 # Write a page with the last build log for this version code
327 wiki_page_path = 'update_' + time.strftime('%s', start_timestamp)
328 newpage = site.Pages[wiki_page_path]
330 txt += "* command line: <code>" + ' '.join(sys.argv) + "</code>\n"
331 txt += "* started at " + common.get_wiki_timestamp(start_timestamp) + '\n'
332 txt += "* completed at " + common.get_wiki_timestamp() + '\n'
333 txt += common.get_git_describe_link()
335 txt += common.get_android_tools_version_log()
336 newpage.save(txt, summary='Run log')
337 newpage = site.Pages['update']
338 newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
341 def delete_disabled_builds(apps, apkcache, repodirs):
342 """Delete disabled build outputs.
344 :param apps: list of all applications, as per metadata.read_metadata
345 :param apkcache: current apk cache information
346 :param repodirs: the repo directories to process
348 for appid, app in apps.items():
349 for build in app['builds']:
350 if not build.disable:
352 apkfilename = common.get_release_filename(app, build)
353 iconfilename = "%s.%s.png" % (
356 for repodir in repodirs:
358 os.path.join(repodir, apkfilename),
359 os.path.join(repodir, apkfilename + '.asc'),
360 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
362 for density in all_screen_densities:
363 repo_dir = get_icon_dir(repodir, density)
364 files.append(os.path.join(repo_dir, iconfilename))
367 if os.path.exists(f):
368 logging.info("Deleting disabled build output " + f)
370 if apkfilename in apkcache:
371 del apkcache[apkfilename]
374 def resize_icon(iconpath, density):
376 if not os.path.isfile(iconpath):
381 fp = open(iconpath, 'rb')
383 size = dpi_to_px(density)
385 if any(length > size for length in im.size):
387 im.thumbnail((size, size), Image.ANTIALIAS)
388 logging.debug("%s was too large at %s - new size is %s" % (
389 iconpath, oldsize, im.size))
390 im.save(iconpath, "PNG", optimize=True,
391 pnginfo=BLANK_PNG_INFO, icc_profile=None)
393 except Exception as e:
394 logging.error(_("Failed resizing {path}: {error}".format(path=iconpath, error=e)))
401 def resize_all_icons(repodirs):
402 """Resize all icons that exceed the max size
404 :param repodirs: the repo directories to process
406 for repodir in repodirs:
407 for density in screen_densities:
408 icon_dir = get_icon_dir(repodir, density)
409 icon_glob = os.path.join(icon_dir, '*.png')
410 for iconpath in glob.glob(icon_glob):
411 resize_icon(iconpath, density)
415 """ Get the signing certificate of an apk. To get the same md5 has that
416 Android gets, we encode the .RSA certificate in a specific format and pass
417 it hex-encoded to the md5 digest algorithm.
419 :param apkpath: path to the apk
420 :returns: A string containing the md5 of the signature of the apk or None
421 if an error occurred.
424 with zipfile.ZipFile(apkpath, 'r') as apk:
425 certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)]
428 logging.error(_("No signing certificates found in {path}").format(path=apkpath))
431 logging.error(_("Found multiple signing certificates in {path}").format(path=apkpath))
434 cert = apk.read(certs[0])
436 cert_encoded = common.get_certificate(cert)
438 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
441 def get_cache_file():
442 return os.path.join('tmp', 'apkcache')
446 """Get the cached dict of the APK index
448 Gather information about all the apk files in the repo directory,
449 using cached data if possible. Some of the index operations take a
450 long time, like calculating the SHA-256 and verifying the APK
453 The cache is invalidated if the metadata version is different, or
454 the 'allow_disabled_algorithms' config/option is different. In
455 those cases, there is no easy way to know what has changed from
456 the cache, so just rerun the whole thing.
461 apkcachefile = get_cache_file()
462 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
463 if not options.clean and os.path.exists(apkcachefile):
464 with open(apkcachefile, 'rb') as cf:
465 apkcache = pickle.load(cf, encoding='utf-8')
466 if apkcache.get("METADATA_VERSION") != METADATA_VERSION \
467 or apkcache.get('allow_disabled_algorithms') != ada:
472 apkcache["METADATA_VERSION"] = METADATA_VERSION
473 apkcache['allow_disabled_algorithms'] = ada
478 def write_cache(apkcache):
479 apkcachefile = get_cache_file()
480 cache_path = os.path.dirname(apkcachefile)
481 if not os.path.exists(cache_path):
482 os.makedirs(cache_path)
483 with open(apkcachefile, 'wb') as cf:
484 pickle.dump(apkcache, cf)
487 def get_icon_bytes(apkzip, iconsrc):
488 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
490 return apkzip.read(iconsrc)
492 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
495 def sha256sum(filename):
496 '''Calculate the sha256 of the given file'''
497 sha = hashlib.sha256()
498 with open(filename, 'rb') as f:
504 return sha.hexdigest()
507 def has_known_vulnerability(filename):
508 """checks for known vulnerabilities in the APK
510 Checks OpenSSL .so files in the APK to see if they are a known vulnerable
511 version. Google also enforces this:
512 https://support.google.com/faqs/answer/6376725?hl=en
514 Checks whether there are more than one classes.dex or AndroidManifest.xml
515 files, which is invalid and an essential part of the "Master Key" attack.
516 http://www.saurik.com/id/17
518 Janus is similar to Master Key but is perhaps easier to scan for.
519 https://www.guardsquare.com/en/blog/new-android-vulnerability-allows-attackers-modify-apps-without-affecting-their-signatures
524 # statically load this pattern
525 if not hasattr(has_known_vulnerability, "pattern"):
526 has_known_vulnerability.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
528 with open(filename.encode(), 'rb') as fp:
530 if first4 != b'\x50\x4b\x03\x04':
531 raise FDroidException(_('{path} has bad file signature "{pattern}", possible Janus exploit!')
532 .format(path=filename, pattern=first4.decode().replace('\n', ' ')) + '\n'
533 + 'https://www.guardsquare.com/en/blog/new-android-vulnerability-allows-attackers-modify-apps-without-affecting-their-signatures')
536 with zipfile.ZipFile(filename) as zf:
537 for name in zf.namelist():
538 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
541 chunk = lib.read(4096)
544 m = has_known_vulnerability.pattern.search(chunk)
546 version = m.group(1).decode('ascii')
547 if (version.startswith('1.0.1') and len(version) > 5 and version[5] >= 'r') \
548 or (version.startswith('1.0.2') and len(version) > 5 and version[5] >= 'f') \
549 or re.match(r'[1-9]\.[1-9]\.[0-9].*', version):
550 logging.debug(_('"{path}" contains recent {name} ({version})')
551 .format(path=filename, name=name, version=version))
553 logging.warning(_('"{path}" contains outdated {name} ({version})')
554 .format(path=filename, name=name, version=version))
557 elif name == 'AndroidManifest.xml' or name == 'classes.dex' or name.endswith('.so'):
558 if name in files_in_apk:
559 logging.warning(_('{apkfilename} has multiple {name} files, looks like Master Key exploit!')
560 .format(apkfilename=filename, name=name))
562 files_in_apk.add(name)
566 def insert_obbs(repodir, apps, apks):
567 """Scans the .obb files in a given repo directory and adds them to the
568 relevant APK instances. OBB files have versionCodes like APK
569 files, and they are loosely associated. If there is an OBB file
570 present, then any APK with the same or higher versionCode will use
571 that OBB file. There are two OBB types: main and patch, each APK
572 can only have only have one of each.
574 https://developer.android.com/google/play/expansion-files.html
576 :param repodir: repo directory to scan
577 :param apps: list of current, valid apps
578 :param apks: current information on all APKs
582 def obbWarnDelete(f, msg):
583 logging.warning(msg + ' ' + f)
584 if options.delete_unknown:
585 logging.error(_("Deleting unknown file: {path}").format(path=f))
589 java_Integer_MIN_VALUE = -pow(2, 31)
590 currentPackageNames = apps.keys()
591 for f in glob.glob(os.path.join(repodir, '*.obb')):
592 obbfile = os.path.basename(f)
593 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
594 chunks = obbfile.split('.')
595 if chunks[0] != 'main' and chunks[0] != 'patch':
596 obbWarnDelete(f, _('OBB filename must start with "main." or "patch.":'))
598 if not re.match(r'^-?[0-9]+$', chunks[1]):
599 obbWarnDelete(f, _('The OBB version code must come after "{name}.":')
600 .format(name=chunks[0]))
602 versionCode = int(chunks[1])
603 packagename = ".".join(chunks[2:-1])
605 highestVersionCode = java_Integer_MIN_VALUE
606 if packagename not in currentPackageNames:
607 obbWarnDelete(f, _("OBB's packagename does not match a supported APK:"))
610 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
611 highestVersionCode = apk['versionCode']
612 if versionCode > highestVersionCode:
613 obbWarnDelete(f, _('OBB file has newer versionCode({integer}) than any APK:')
614 .format(integer=str(versionCode)))
616 obbsha256 = sha256sum(f)
617 obbs.append((packagename, versionCode, obbfile, obbsha256))
620 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
621 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
622 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
623 apk['obbMainFile'] = obbfile
624 apk['obbMainFileSha256'] = obbsha256
625 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
626 apk['obbPatchFile'] = obbfile
627 apk['obbPatchFileSha256'] = obbsha256
628 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
632 def translate_per_build_anti_features(apps, apks):
633 """Grab the anti-features list from the build metadata
635 For most Anti-Features, they are really most applicable per-APK,
636 not for an app. An app can fix a vulnerability, add/remove
637 tracking, etc. This reads the 'antifeatures' list from the Build
638 entries in the fdroiddata metadata file, then transforms it into
639 the 'antiFeatures' list of unique items for the index.
641 The field key is all lower case in the metadata file to match the
642 rest of the Build fields. It is 'antiFeatures' camel case in the
643 implementation, index, and fdroidclient since it is translated
644 from the build 'antifeatures' field, not directly included.
648 antiFeatures = dict()
649 for packageName, app in apps.items():
651 for build in app['builds']:
652 afl = build.get('antifeatures')
654 d[int(build.versionCode)] = afl
656 antiFeatures[packageName] = d
659 d = antiFeatures.get(apk['packageName'])
661 afl = d.get(apk['versionCode'])
663 apk['antiFeatures'].update(afl)
666 def _get_localized_dict(app, locale):
667 '''get the dict to add localized store metadata to'''
668 if 'localized' not in app:
669 app['localized'] = collections.OrderedDict()
670 if locale not in app['localized']:
671 app['localized'][locale] = collections.OrderedDict()
672 return app['localized'][locale]
675 def _set_localized_text_entry(app, locale, key, f):
676 limit = config['char_limits'][key]
677 localized = _get_localized_dict(app, locale)
679 text = fp.read()[:limit]
681 localized[key] = text
684 def _set_author_entry(app, key, f):
685 limit = config['char_limits']['author']
687 text = fp.read()[:limit]
692 def _strip_and_copy_image(inpath, outpath):
693 """Remove any metadata from image and copy it to new path
695 Sadly, image metadata like EXIF can be used to exploit devices.
696 It is not used at all in the F-Droid ecosystem, so its much safer
697 just to remove it entirely.
701 extension = common.get_extension(inpath)[1]
702 if os.path.isdir(outpath):
703 outpath = os.path.join(outpath, os.path.basename(inpath))
704 if extension == 'png':
705 with open(inpath, 'rb') as fp:
706 in_image = Image.open(fp)
707 in_image.save(outpath, "PNG", optimize=True,
708 pnginfo=BLANK_PNG_INFO, icc_profile=None)
709 elif extension == 'jpg' or extension == 'jpeg':
710 with open(inpath, 'rb') as fp:
711 in_image = Image.open(fp)
712 data = list(in_image.getdata())
713 out_image = Image.new(in_image.mode, in_image.size)
714 out_image.putdata(data)
715 out_image.save(outpath, "JPEG", optimize=True)
717 raise FDroidException(_('Unsupported file type "{extension}" for repo graphic')
718 .format(extension=extension))
721 def copy_triple_t_store_metadata(apps):
722 """Include store metadata from the app's source repo
724 The Triple-T Gradle Play Publisher is a plugin that has a standard
725 file layout for all of the metadata and graphics that the Google
726 Play Store accepts. Since F-Droid has the git repo, it can just
727 pluck those files directly. This method reads any text files into
728 the app dict, then copies any graphics into the fdroid repo
731 This needs to be run before insert_localized_app_metadata() so that
732 the graphics files that are copied into the fdroid repo get
735 https://github.com/Triple-T/gradle-play-publisher#upload-images
736 https://github.com/Triple-T/gradle-play-publisher#play-store-metadata
740 if not os.path.isdir('build'):
741 return # nothing to do
743 for packageName, app in apps.items():
744 for d in glob.glob(os.path.join('build', packageName, '*', 'src', '*', 'play')):
745 logging.debug('Triple-T Gradle Play Publisher: ' + d)
746 for root, dirs, files in os.walk(d):
747 segments = root.split('/')
748 locale = segments[-2]
750 if f == 'fulldescription':
751 _set_localized_text_entry(app, locale, 'description',
752 os.path.join(root, f))
754 elif f == 'shortdescription':
755 _set_localized_text_entry(app, locale, 'summary',
756 os.path.join(root, f))
759 _set_localized_text_entry(app, locale, 'name',
760 os.path.join(root, f))
763 _set_localized_text_entry(app, locale, 'video',
764 os.path.join(root, f))
766 elif f == 'whatsnew':
767 _set_localized_text_entry(app, segments[-1], 'whatsNew',
768 os.path.join(root, f))
770 elif f == 'contactEmail':
771 _set_author_entry(app, 'authorEmail', os.path.join(root, f))
773 elif f == 'contactPhone':
774 _set_author_entry(app, 'authorPhone', os.path.join(root, f))
776 elif f == 'contactWebsite':
777 _set_author_entry(app, 'authorWebSite', os.path.join(root, f))
780 base, extension = common.get_extension(f)
781 dirname = os.path.basename(root)
782 if extension in ALLOWED_EXTENSIONS \
783 and (dirname in GRAPHIC_NAMES or dirname in SCREENSHOT_DIRS):
784 if segments[-2] == 'listing':
785 locale = segments[-3]
787 locale = segments[-2]
788 destdir = os.path.join('repo', packageName, locale, dirname)
789 os.makedirs(destdir, mode=0o755, exist_ok=True)
790 sourcefile = os.path.join(root, f)
791 destfile = os.path.join(destdir, os.path.basename(f))
792 logging.debug('copying ' + sourcefile + ' ' + destfile)
793 _strip_and_copy_image(sourcefile, destfile)
796 def insert_localized_app_metadata(apps):
797 """scans standard locations for graphics and localized text
799 Scans for localized description files, store graphics, and
800 screenshot PNG files in statically defined screenshots directory
801 and adds them to the app metadata. The screenshots and graphic
802 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
803 and must be in the following layout:
804 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
806 repo/packageName/locale/featureGraphic.png
807 repo/packageName/locale/phoneScreenshots/1.png
808 repo/packageName/locale/phoneScreenshots/2.png
810 The changelog files must be text files named with the versionCode
811 ending with ".txt" and must be in the following layout:
812 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
814 repo/packageName/locale/changelogs/12345.txt
816 This will scan the each app's source repo then the metadata/ dir
817 for these standard locations of changelog files. If it finds
818 them, they will be added to the dict of all packages, with the
819 versions in the metadata/ folder taking precendence over the what
820 is in the app's source repo.
822 Where "packageName" is the app's packageName and "locale" is the locale
823 of the graphics, e.g. what language they are in, using the IETF RFC5646
824 format (en-US, fr-CA, es-MX, etc).
826 This will also scan the app's git for a fastlane folder, and the
827 metadata/ folder and the apps' source repos for standard locations
828 of graphic and screenshot files. If it finds them, it will copy
829 them into the repo. The fastlane files follow this pattern:
830 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
834 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'src', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
835 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
836 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
837 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
839 for srcd in sorted(sourcedirs):
840 if not os.path.isdir(srcd):
842 for root, dirs, files in os.walk(srcd):
843 segments = root.split('/')
844 packageName = segments[1]
845 if packageName not in apps:
846 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
848 locale = segments[-1]
849 destdir = os.path.join('repo', packageName, locale)
851 # flavours specified in build receipt
853 if apps[packageName] and 'builds' in apps[packageName] and len(apps[packageName].builds) > 0\
854 and 'gradle' in apps[packageName].builds[-1]:
855 build_flavours = apps[packageName].builds[-1].gradle
857 if len(segments) >= 5 and segments[4] == "fastlane" and segments[3] not in build_flavours:
858 logging.debug("ignoring due to wrong flavour")
862 if f in ('description.txt', 'full_description.txt'):
863 _set_localized_text_entry(apps[packageName], locale, 'description',
864 os.path.join(root, f))
866 elif f in ('summary.txt', 'short_description.txt'):
867 _set_localized_text_entry(apps[packageName], locale, 'summary',
868 os.path.join(root, f))
870 elif f in ('name.txt', 'title.txt'):
871 _set_localized_text_entry(apps[packageName], locale, 'name',
872 os.path.join(root, f))
874 elif f == 'video.txt':
875 _set_localized_text_entry(apps[packageName], locale, 'video',
876 os.path.join(root, f))
878 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
879 locale = segments[-2]
880 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
881 os.path.join(root, f))
884 base, extension = common.get_extension(f)
885 if locale == 'images':
886 locale = segments[-2]
887 destdir = os.path.join('repo', packageName, locale)
888 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
889 os.makedirs(destdir, mode=0o755, exist_ok=True)
890 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
891 _strip_and_copy_image(os.path.join(root, f), destdir)
893 if d in SCREENSHOT_DIRS:
894 if locale == 'images':
895 locale = segments[-2]
896 destdir = os.path.join('repo', packageName, locale)
897 for f in glob.glob(os.path.join(root, d, '*.*')):
898 _ignored, extension = common.get_extension(f)
899 if extension in ALLOWED_EXTENSIONS:
900 screenshotdestdir = os.path.join(destdir, d)
901 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
902 logging.debug('copying ' + f + ' ' + screenshotdestdir)
903 _strip_and_copy_image(f, screenshotdestdir)
905 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
907 if not os.path.isdir(d):
909 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
910 if not os.path.isfile(f):
912 segments = f.split('/')
913 packageName = segments[1]
915 screenshotdir = segments[3]
916 filename = os.path.basename(f)
917 base, extension = common.get_extension(filename)
919 if packageName not in apps:
920 logging.warning(_('Found "{path}" graphic without metadata for app "{name}"!')
921 .format(path=filename, name=packageName))
923 graphics = _get_localized_dict(apps[packageName], locale)
925 if extension not in ALLOWED_EXTENSIONS:
926 logging.warning(_('Only PNG and JPEG are supported for graphics, found: {path}').format(path=f))
927 elif base in GRAPHIC_NAMES:
928 # there can only be zero or one of these per locale
929 graphics[base] = filename
930 elif screenshotdir in SCREENSHOT_DIRS:
931 # there can any number of these per locale
932 logging.debug(_('adding to {name}: {path}').format(name=screenshotdir, path=f))
933 if screenshotdir not in graphics:
934 graphics[screenshotdir] = []
935 graphics[screenshotdir].append(filename)
937 logging.warning(_('Unsupported graphics file found: {path}').format(path=f))
940 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
941 """Scan a repo for all files with an extension except APK/OBB
943 :param apkcache: current cached info about all repo files
944 :param repodir: repo directory to scan
945 :param knownapks: list of all known files, as per metadata.read_metadata
946 :param use_date_from_file: use date from file (instead of current date)
947 for newly added files
952 repodir = repodir.encode('utf-8')
953 for name in os.listdir(repodir):
954 file_extension = common.get_file_extension(name)
955 if file_extension == 'apk' or file_extension == 'obb':
957 filename = os.path.join(repodir, name)
958 name_utf8 = name.decode('utf-8')
959 if filename.endswith(b'_src.tar.gz'):
960 logging.debug(_('skipping source tarball: {path}')
961 .format(path=filename.decode('utf-8')))
963 if not common.is_repo_file(filename):
965 stat = os.stat(filename)
966 if stat.st_size == 0:
967 raise FDroidException(_('{path} is zero size!')
968 .format(path=filename))
970 shasum = sha256sum(filename)
973 repo_file = apkcache[name]
974 # added time is cached as tuple but used here as datetime instance
975 if 'added' in repo_file:
976 a = repo_file['added']
977 if isinstance(a, datetime):
978 repo_file['added'] = a
980 repo_file['added'] = datetime(*a[:6])
981 if repo_file.get('hash') == shasum:
982 logging.debug(_("Reading {apkfilename} from cache")
983 .format(apkfilename=name_utf8))
986 logging.debug(_("Ignoring stale cache data for {apkfilename}")
987 .format(apkfilename=name_utf8))
990 logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8))
991 repo_file = collections.OrderedDict()
992 repo_file['name'] = os.path.splitext(name_utf8)[0]
993 # TODO rename apkname globally to something more generic
994 repo_file['apkName'] = name_utf8
995 repo_file['hash'] = shasum
996 repo_file['hashType'] = 'sha256'
997 repo_file['versionCode'] = 0
998 repo_file['versionName'] = shasum
999 # the static ID is the SHA256 unless it is set in the metadata
1000 repo_file['packageName'] = shasum
1002 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
1004 repo_file['packageName'] = m.group(1)
1005 repo_file['versionCode'] = int(m.group(2))
1006 srcfilename = name + b'_src.tar.gz'
1007 if os.path.exists(os.path.join(repodir, srcfilename)):
1008 repo_file['srcname'] = srcfilename.decode('utf-8')
1009 repo_file['size'] = stat.st_size
1011 apkcache[name] = repo_file
1014 if use_date_from_file:
1015 timestamp = stat.st_ctime
1016 default_date_param = time.gmtime(time.mktime(datetime.fromtimestamp(timestamp).timetuple()))
1018 default_date_param = None
1020 # Record in knownapks, getting the added date at the same time..
1021 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
1022 default_date=default_date_param)
1024 repo_file['added'] = added
1026 repo_files.append(repo_file)
1028 return repo_files, cachechanged
1031 def scan_apk(apk_file):
1033 Scans an APK file and returns dictionary with metadata of the APK.
1035 Attention: This does *not* verify that the APK signature is correct.
1037 :param apk_file: The (ideally absolute) path to the APK file
1038 :raises BuildException
1039 :return A dict containing APK metadata
1042 'hash': sha256sum(apk_file),
1043 'hashType': 'sha256',
1044 'uses-permission': [],
1045 'uses-permission-sdk-23': [],
1049 'antiFeatures': set(),
1052 if common.use_androguard():
1053 scan_apk_androguard(apk, apk_file)
1055 scan_apk_aapt(apk, apk_file)
1057 # Get the signature, or rather the signing key fingerprints
1058 logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
1059 apk['sig'] = getsig(apk_file)
1061 raise BuildException("Failed to get apk signature")
1062 apk['signer'] = common.apk_signer_fingerprint(os.path.join(os.getcwd(),
1064 if not apk.get('signer'):
1065 raise BuildException("Failed to get apk signing key fingerprint")
1067 # Get size of the APK
1068 apk['size'] = os.path.getsize(apk_file)
1070 if 'minSdkVersion' not in apk:
1071 logging.warning("No SDK version information found in {0}".format(apk_file))
1072 apk['minSdkVersion'] = 3 # aapt defaults to 3 as the min
1073 if 'targetSdkVersion' not in apk:
1074 apk['targetSdkVersion'] = apk['minSdkVersion']
1076 # Check for known vulnerabilities
1077 if has_known_vulnerability(apk_file):
1078 apk['antiFeatures'].add('KnownVuln')
1083 def _get_apk_icons_src(apkfile, icon_name):
1084 """Extract the paths to the app icon in all available densities
1088 density_re = re.compile('^res/(.*)/' + icon_name + '\.(png|xml)$')
1089 with zipfile.ZipFile(apkfile) as zf:
1090 for filename in zf.namelist():
1091 m = density_re.match(filename)
1093 folder = m.group(1).split('-')
1094 if len(folder) > 1 and folder[1].endswith('dpi'):
1095 density = screen_resolutions[folder[1]]
1098 icons_src[density] = m.group(0)
1099 if icons_src.get('-1') is None and '160' in icons_src:
1100 icons_src['-1'] = icons_src['160']
1104 def scan_apk_aapt(apk, apkfile):
1105 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1106 if p.returncode != 0:
1107 if options.delete_unknown:
1108 if os.path.exists(apkfile):
1109 logging.error(_("Failed to get apk information, deleting {path}").format(path=apkfile))
1112 logging.error("Could not find {0} to remove it".format(apkfile))
1114 logging.error(_("Failed to get apk information, skipping {path}").format(path=apkfile))
1115 raise BuildException(_("Invalid APK"))
1117 for line in p.output.splitlines():
1118 if line.startswith("package:"):
1120 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
1121 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
1122 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
1123 except Exception as e:
1124 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
1125 elif line.startswith("application:"):
1126 m = re.match(APK_LABEL_ICON_PAT, line)
1128 apk['name'] = m.group(1)
1129 icon_name = os.path.splitext(os.path.basename(m.group(2)))[0]
1130 elif not apk.get('name') and line.startswith("launchable-activity:"):
1131 # Only use launchable-activity as fallback to application
1132 apk['name'] = re.match(APK_LABEL_ICON_PAT, line).group(1)
1133 elif line.startswith("sdkVersion:"):
1134 m = re.match(APK_SDK_VERSION_PAT, line)
1136 logging.error(line.replace('sdkVersion:', '')
1137 + ' is not a valid minSdkVersion!')
1139 apk['minSdkVersion'] = m.group(1)
1140 elif line.startswith("targetSdkVersion:"):
1141 m = re.match(APK_SDK_VERSION_PAT, line)
1143 logging.error(line.replace('targetSdkVersion:', '')
1144 + ' is not a valid targetSdkVersion!')
1146 apk['targetSdkVersion'] = m.group(1)
1147 elif line.startswith("maxSdkVersion:"):
1148 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
1149 elif line.startswith("native-code:"):
1150 apk['nativecode'] = []
1151 for arch in line[13:].split(' '):
1152 apk['nativecode'].append(arch[1:-1])
1153 elif line.startswith('uses-permission:'):
1154 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1155 if perm_match['maxSdkVersion']:
1156 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1157 permission = UsesPermission(
1159 perm_match['maxSdkVersion']
1162 apk['uses-permission'].append(permission)
1163 elif line.startswith('uses-permission-sdk-23:'):
1164 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1165 if perm_match['maxSdkVersion']:
1166 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1167 permission_sdk_23 = UsesPermissionSdk23(
1169 perm_match['maxSdkVersion']
1172 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1174 elif line.startswith('uses-feature:'):
1175 feature = re.match(APK_FEATURE_PAT, line).group(1)
1176 # Filter out this, it's only added with the latest SDK tools and
1177 # causes problems for lots of apps.
1178 if feature != "android.hardware.screen.portrait" \
1179 and feature != "android.hardware.screen.landscape":
1180 if feature.startswith("android.feature."):
1181 feature = feature[16:]
1182 apk['features'].add(feature)
1183 apk['icons_src'] = _get_apk_icons_src(apkfile, icon_name)
1186 def scan_apk_androguard(apk, apkfile):
1188 from androguard.core.bytecodes.apk import APK
1189 apkobject = APK(apkfile)
1190 if apkobject.is_valid_APK():
1191 arsc = apkobject.get_android_resources()
1193 if options.delete_unknown:
1194 if os.path.exists(apkfile):
1195 logging.error(_("Failed to get apk information, deleting {path}")
1196 .format(path=apkfile))
1199 logging.error(_("Could not find {path} to remove it")
1200 .format(path=apkfile))
1202 logging.error(_("Failed to get apk information, skipping {path}")
1203 .format(path=apkfile))
1204 raise BuildException(_("Invalid APK"))
1206 raise FDroidException("androguard library is not installed and aapt not present")
1207 except FileNotFoundError:
1208 logging.error(_("Could not open apk file for analysis"))
1209 raise BuildException(_("Invalid APK"))
1211 apk['packageName'] = apkobject.get_package()
1212 apk['versionCode'] = int(apkobject.get_androidversion_code())
1213 apk['versionName'] = apkobject.get_androidversion_name()
1214 if apk['versionName'][0] == "@":
1215 version_id = int(apk['versionName'].replace("@", "0x"), 16)
1216 version_id = arsc.get_id(apk['packageName'], version_id)[1]
1217 apk['versionName'] = arsc.get_string(apk['packageName'], version_id)[1]
1218 apk['name'] = apkobject.get_app_name()
1220 if apkobject.get_max_sdk_version() is not None:
1221 apk['maxSdkVersion'] = apkobject.get_max_sdk_version()
1222 if apkobject.get_min_sdk_version() is not None:
1223 apk['minSdkVersion'] = apkobject.get_min_sdk_version()
1224 if apkobject.get_target_sdk_version() is not None:
1225 apk['targetSdkVersion'] = apkobject.get_target_sdk_version()
1227 icon_id_str = apkobject.get_element("application", "icon")
1229 icon_id = int(icon_id_str.replace("@", "0x"), 16)
1230 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1231 apk['icons_src'] = _get_apk_icons_src(apkfile, icon_name)
1233 arch_re = re.compile("^lib/(.*)/.*$")
1234 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1236 apk['nativecode'] = []
1237 apk['nativecode'].extend(sorted(list(arch)))
1239 xml = apkobject.get_android_manifest_xml()
1241 for item in xml.findall('uses-permission'):
1242 name = str(item.attrib['{' + xml.nsmap['android'] + '}name'])
1243 maxSdkVersion = item.attrib.get('{' + xml.nsmap['android'] + '}maxSdkVersion')
1244 maxSdkVersion = int(maxSdkVersion) if maxSdkVersion else None
1245 permission = UsesPermission(
1249 apk['uses-permission'].append(permission)
1250 for name, maxSdkVersion in apkobject.get_uses_implied_permission_list():
1251 permission = UsesPermission(
1255 apk['uses-permission'].append(permission)
1257 for item in xml.findall('uses-permission-sdk-23'):
1258 name = str(item.attrib['{' + xml.nsmap['android'] + '}name'])
1259 maxSdkVersion = item.attrib.get('{' + xml.nsmap['android'] + '}maxSdkVersion')
1260 maxSdkVersion = int(maxSdkVersion) if maxSdkVersion else None
1261 permission_sdk_23 = UsesPermissionSdk23(
1265 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1267 for item in xml.findall('uses-feature'):
1268 key = '{' + xml.nsmap['android'] + '}name'
1269 if key not in item.attrib:
1271 feature = str(item.attrib[key])
1272 if feature != "android.hardware.screen.portrait" \
1273 and feature != "android.hardware.screen.landscape":
1274 if feature.startswith("android.feature."):
1275 feature = feature[16:]
1276 required = item.attrib.get('{' + xml.nsmap['android'] + '}required')
1277 if required is None or required == 'true':
1278 apk['features'].append(feature)
1281 def process_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1282 allow_disabled_algorithms=False, archive_bad_sig=False):
1283 """Processes the apk with the given filename in the given repo directory.
1285 This also extracts the icons.
1287 :param apkcache: current apk cache information
1288 :param apkfilename: the filename of the apk to scan
1289 :param repodir: repo directory to scan
1290 :param knownapks: known apks info
1291 :param use_date_from_apk: use date from APK (instead of current date)
1292 for newly added APKs
1293 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1294 disabled algorithms in the signature (e.g. MD5)
1295 :param archive_bad_sig: move APKs with a bad signature to the archive
1296 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1297 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1301 apkfile = os.path.join(repodir, apkfilename)
1303 cachechanged = False
1305 if apkfilename in apkcache:
1306 apk = apkcache[apkfilename]
1307 if apk.get('hash') == sha256sum(apkfile):
1308 logging.debug(_("Reading {apkfilename} from cache")
1309 .format(apkfilename=apkfilename))
1312 logging.debug(_("Ignoring stale cache data for {apkfilename}")
1313 .format(apkfilename=apkfilename))
1316 logging.debug(_("Processing {apkfilename}").format(apkfilename=apkfilename))
1319 apk = scan_apk(apkfile)
1320 except BuildException:
1321 logging.warning(_("Skipping '{apkfilename}' with invalid signature!")
1322 .format(apkfilename=apkfilename))
1323 return True, None, False
1325 # Check for debuggable apks...
1326 if common.is_apk_and_debuggable(apkfile):
1327 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1329 if options.rename_apks:
1330 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1331 std_short_name = os.path.join(repodir, n)
1332 if apkfile != std_short_name:
1333 if os.path.exists(std_short_name):
1334 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1335 if apkfile != std_long_name:
1336 if os.path.exists(std_long_name):
1337 dupdir = os.path.join('duplicates', repodir)
1338 if not os.path.isdir(dupdir):
1339 os.makedirs(dupdir, exist_ok=True)
1340 dupfile = os.path.join('duplicates', std_long_name)
1341 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1342 os.rename(apkfile, dupfile)
1343 return True, None, False
1345 os.rename(apkfile, std_long_name)
1346 apkfile = std_long_name
1348 os.rename(apkfile, std_short_name)
1349 apkfile = std_short_name
1350 apkfilename = apkfile[len(repodir) + 1:]
1352 apk['apkName'] = apkfilename
1353 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1354 if os.path.exists(os.path.join(repodir, srcfilename)):
1355 apk['srcname'] = srcfilename
1357 # verify the jar signature is correct, allow deprecated
1358 # algorithms only if the APK is in the archive.
1360 if not common.verify_apk_signature(apkfile):
1361 if repodir == 'archive' or allow_disabled_algorithms:
1362 if common.verify_old_apk_signature(apkfile):
1363 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1371 logging.warning(_('Archiving {apkfilename} with invalid signature!')
1372 .format(apkfilename=apkfilename))
1373 move_apk_between_sections(repodir, 'archive', apk)
1375 logging.warning(_('Skipping {apkfilename} with invalid signature!')
1376 .format(apkfilename=apkfilename))
1377 return True, None, False
1379 apkzip = zipfile.ZipFile(apkfile, 'r')
1381 manifest = apkzip.getinfo('AndroidManifest.xml')
1382 # 1980-0-0 means zeroed out, any other invalid date should trigger a warning
1383 if (1980, 0, 0) != manifest.date_time[0:3]:
1385 common.check_system_clock(datetime(*manifest.date_time), apkfilename)
1386 except ValueError as e:
1387 logging.warning(_("{apkfilename}'s AndroidManifest.xml has a bad date: ")
1388 .format(apkfilename=apkfile) + str(e))
1390 # extract icons from APK zip file
1391 iconfilename = "%s.%s" % (apk['packageName'], apk['versionCode'])
1393 empty_densities = extract_apk_icons(iconfilename, apk, apkzip, repodir)
1395 apkzip.close() # ensure that APK zip file gets closed
1397 # resize existing icons for densities missing in the APK
1398 fill_missing_icon_densities(empty_densities, iconfilename, apk, repodir)
1400 if use_date_from_apk and manifest.date_time[1] != 0:
1401 default_date_param = datetime(*manifest.date_time)
1403 default_date_param = None
1405 # Record in known apks, getting the added date at the same time..
1406 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1407 default_date=default_date_param)
1409 apk['added'] = added
1411 apkcache[apkfilename] = apk
1414 return False, apk, cachechanged
1417 def process_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1418 """Processes the apks in the given repo directory.
1420 This also extracts the icons.
1422 :param apkcache: current apk cache information
1423 :param repodir: repo directory to scan
1424 :param knownapks: known apks info
1425 :param use_date_from_apk: use date from APK (instead of current date)
1426 for newly added APKs
1427 :returns: (apks, cachechanged) where apks is a list of apk information,
1428 and cachechanged is True if the apkcache got changed.
1431 cachechanged = False
1433 for icon_dir in get_all_icon_dirs(repodir):
1434 if os.path.exists(icon_dir):
1436 shutil.rmtree(icon_dir)
1437 os.makedirs(icon_dir)
1439 os.makedirs(icon_dir)
1442 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1443 apkfilename = apkfile[len(repodir) + 1:]
1444 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1445 (skip, apk, cachethis) = process_apk(apkcache, apkfilename, repodir, knownapks,
1446 use_date_from_apk, ada, True)
1450 cachechanged = cachechanged or cachethis
1452 return apks, cachechanged
1455 def extract_apk_icons(icon_filename, apk, apkzip, repo_dir):
1456 """Extracts PNG icons from an APK with the supported pixel densities
1458 Extracts icons from the given APK zip in various densities, saves
1459 them into given repo directory and stores their names in the APK
1460 metadata dictionary. If the icon is an XML icon, then this tries
1461 to find PNG icon that can replace it.
1463 :param icon_filename: A string representing the icon's file name
1464 :param apk: A populated dictionary containing APK metadata.
1465 Needs to have 'icons_src' key
1466 :param apkzip: An opened zipfile.ZipFile of the APK file
1467 :param repo_dir: The directory of the APK's repository
1468 :return: A list of icon densities that are missing
1471 res_name_re = re.compile(r'res/(drawable|mipmap)-(x*[hlm]dpi|anydpi).*/(.*)_[0-9]+dp.(png|xml)')
1473 for f in apkzip.namelist():
1474 m = res_name_re.match(f)
1475 if m and m.group(4) == 'png':
1476 density = screen_resolutions[m.group(2)]
1477 pngs[m.group(3) + '/' + density] = m.group(0)
1480 empty_densities = []
1481 for density in screen_densities:
1482 if density not in apk['icons_src']:
1483 empty_densities.append(density)
1485 icon_src = apk['icons_src'][density]
1486 icon_dir = get_icon_dir(repo_dir, density)
1489 # Extract the icon files per density
1490 if icon_src.endswith('.xml'):
1491 m = res_name_re.match(icon_src)
1493 name = pngs.get(m.group(3) + '/' + str(density))
1496 if icon_src.endswith('.xml'):
1497 empty_densities.append(density)
1499 icon_dest = os.path.join(icon_dir, icon_filename + icon_type)
1502 with open(icon_dest, 'wb') as f:
1503 f.write(get_icon_bytes(apkzip, icon_src))
1504 apk['icons'][density] = icon_filename + icon_type
1505 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1506 logging.warning("Error retrieving icon file: %s %s", icon_dest, e)
1507 del apk['icons_src'][density]
1508 empty_densities.append(density)
1510 # '-1' here is a remnant of the parsing of aapt output, meaning "no DPI specified"
1511 if '-1' in apk['icons_src']:
1512 icon_src = apk['icons_src']['-1']
1513 icon_type = icon_src[-4:]
1514 icon_path = os.path.join(get_icon_dir(repo_dir, '0'), icon_filename + icon_type)
1515 with open(icon_path, 'wb') as f:
1516 f.write(get_icon_bytes(apkzip, icon_src))
1517 if icon_type == '.png':
1520 im = Image.open(icon_path)
1521 dpi = px_to_dpi(im.size[0])
1522 for density in screen_densities:
1523 if density in apk['icons']:
1525 if density == screen_densities[-1] or dpi >= int(density):
1526 apk['icons'][density] = icon_filename + icon_type
1527 shutil.move(icon_path,
1528 os.path.join(get_icon_dir(repo_dir, density), icon_filename + icon_type))
1529 empty_densities.remove(density)
1531 except Exception as e:
1532 logging.warning(_("Failed reading {path}: {error}")
1533 .format(path=icon_path, error=e))
1535 if im and hasattr(im, 'close'):
1539 apk['icon'] = icon_filename + icon_type
1541 return empty_densities
1544 def fill_missing_icon_densities(empty_densities, icon_filename, apk, repo_dir):
1546 Resize existing PNG icons for densities missing in the APK to ensure all densities are available
1548 :param empty_densities: A list of icon densities that are missing
1549 :param icon_filename: A string representing the icon's file name
1550 :param apk: A populated dictionary containing APK metadata. Needs to have 'icons' key
1551 :param repo_dir: The directory of the APK's repository
1554 icon_filename += '.png'
1555 # First try resizing down to not lose quality
1557 for density in screen_densities:
1558 if density == '65534': # not possible to generate 'anydpi' from other densities
1560 if density not in empty_densities:
1561 last_density = density
1563 if last_density is None:
1565 logging.debug("Density %s not available, resizing down from %s", density, last_density)
1567 last_icon_path = os.path.join(get_icon_dir(repo_dir, last_density), icon_filename)
1568 icon_path = os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1571 fp = open(last_icon_path, 'rb')
1574 size = dpi_to_px(density)
1576 im.thumbnail((size, size), Image.ANTIALIAS)
1577 im.save(icon_path, "PNG", optimize=True,
1578 pnginfo=BLANK_PNG_INFO, icc_profile=None)
1579 empty_densities.remove(density)
1580 except Exception as e:
1581 logging.warning("Invalid image file at %s: %s", last_icon_path, e)
1586 # Then just copy from the highest resolution available
1588 for density in reversed(screen_densities):
1589 if density not in empty_densities:
1590 last_density = density
1593 if last_density is None:
1597 os.path.join(get_icon_dir(repo_dir, last_density), icon_filename),
1598 os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1600 empty_densities.remove(density)
1602 for density in screen_densities:
1603 icon_dir = get_icon_dir(repo_dir, density)
1604 icon_dest = os.path.join(icon_dir, icon_filename)
1605 resize_icon(icon_dest, density)
1607 # Copy from icons-mdpi to icons since mdpi is the baseline density
1608 baseline = os.path.join(get_icon_dir(repo_dir, '160'), icon_filename)
1609 if os.path.isfile(baseline):
1610 apk['icons']['0'] = icon_filename
1611 shutil.copyfile(baseline, os.path.join(get_icon_dir(repo_dir, '0'), icon_filename))
1614 def apply_info_from_latest_apk(apps, apks):
1616 Some information from the apks needs to be applied up to the application level.
1617 When doing this, we use the info from the most recent version's apk.
1618 We deal with figuring out when the app was added and last updated at the same time.
1620 for appid, app in apps.items():
1621 bestver = UNSET_VERSION_CODE
1623 if apk['packageName'] == appid:
1624 if apk['versionCode'] > bestver:
1625 bestver = apk['versionCode']
1629 if not app.added or apk['added'] < app.added:
1630 app.added = apk['added']
1631 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1632 app.lastUpdated = apk['added']
1635 logging.debug("Don't know when " + appid + " was added")
1636 if not app.lastUpdated:
1637 logging.debug("Don't know when " + appid + " was last updated")
1639 if bestver == UNSET_VERSION_CODE:
1641 if app.Name is None:
1642 app.Name = app.AutoName or appid
1644 logging.debug("Application " + appid + " has no packages")
1646 if app.Name is None:
1647 app.Name = bestapk['name']
1648 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1649 if app.CurrentVersionCode is None:
1650 app.CurrentVersionCode = str(bestver)
1653 def make_categories_txt(repodir, categories):
1654 '''Write a category list in the repo to allow quick access'''
1656 for cat in sorted(categories):
1657 catdata += cat + '\n'
1658 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1662 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1664 def filter_apk_list_sorted(apk_list):
1666 for apk in apk_list:
1667 if apk['packageName'] == appid:
1670 # Sort the apk list by version code. First is highest/newest.
1671 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1673 for appid, app in apps.items():
1675 if app.ArchivePolicy:
1676 keepversions = int(app.ArchivePolicy[:-9])
1678 keepversions = defaultkeepversions
1680 logging.debug(_("Checking archiving for {appid} - apks:{integer}, keepversions:{keep}, archapks:{arch}")
1681 .format(appid=appid, integer=len(apks), keep=keepversions, arch=len(archapks)))
1683 current_app_apks = filter_apk_list_sorted(apks)
1684 if len(current_app_apks) > keepversions:
1685 # Move back the ones we don't want.
1686 for apk in current_app_apks[keepversions:]:
1687 move_apk_between_sections(repodir, archivedir, apk)
1688 archapks.append(apk)
1691 current_app_archapks = filter_apk_list_sorted(archapks)
1692 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1694 # Move forward the ones we want again, except DisableAlgorithm
1695 for apk in current_app_archapks:
1696 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1697 move_apk_between_sections(archivedir, repodir, apk)
1698 archapks.remove(apk)
1701 if kept == keepversions:
1705 def move_apk_between_sections(from_dir, to_dir, apk):
1706 """move an APK from repo to archive or vice versa"""
1708 def _move_file(from_dir, to_dir, filename, ignore_missing):
1709 from_path = os.path.join(from_dir, filename)
1710 if ignore_missing and not os.path.exists(from_path):
1712 to_path = os.path.join(to_dir, filename)
1713 if not os.path.exists(to_dir):
1715 shutil.move(from_path, to_path)
1717 if from_dir == to_dir:
1720 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1721 _move_file(from_dir, to_dir, apk['apkName'], False)
1722 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1723 for density in all_screen_densities:
1724 from_icon_dir = get_icon_dir(from_dir, density)
1725 to_icon_dir = get_icon_dir(to_dir, density)
1726 if density not in apk.get('icons', []):
1728 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1729 if 'srcname' in apk:
1730 _move_file(from_dir, to_dir, apk['srcname'], False)
1733 def add_apks_to_per_app_repos(repodir, apks):
1734 apks_per_app = dict()
1736 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1737 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1738 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1739 apks_per_app[apk['packageName']] = apk
1741 if not os.path.exists(apk['per_app_icons']):
1742 logging.info(_('Adding new repo for only {name}').format(name=apk['packageName']))
1743 os.makedirs(apk['per_app_icons'])
1745 apkpath = os.path.join(repodir, apk['apkName'])
1746 shutil.copy(apkpath, apk['per_app_repo'])
1747 apksigpath = apkpath + '.sig'
1748 if os.path.exists(apksigpath):
1749 shutil.copy(apksigpath, apk['per_app_repo'])
1750 apkascpath = apkpath + '.asc'
1751 if os.path.exists(apkascpath):
1752 shutil.copy(apkascpath, apk['per_app_repo'])
1755 def create_metadata_from_template(apk):
1756 '''create a new metadata file using internal or external template
1758 Generate warnings for apk's with no metadata (or create skeleton
1759 metadata files, if requested on the command line). Though the
1760 template file is YAML, this uses neither pyyaml nor ruamel.yaml
1761 since those impose things on the metadata file made from the
1762 template: field sort order, empty field value, formatting, etc.
1766 if os.path.exists('template.yml'):
1767 with open('template.yml') as f:
1769 if 'name' in apk and apk['name'] != '':
1770 metatxt = re.sub(r'''^(((Auto)?Name|Summary):)[ '"\.]*$''',
1771 r'\1 ' + apk['name'],
1773 flags=re.IGNORECASE | re.MULTILINE)
1775 logging.warning(_('{appid} does not have a name! Using package name instead.')
1776 .format(appid=apk['packageName']))
1777 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1778 r'\1 ' + apk['packageName'],
1780 flags=re.IGNORECASE | re.MULTILINE)
1781 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1785 app['Categories'] = [os.path.basename(os.getcwd())]
1786 # include some blanks as part of the template
1787 app['AuthorName'] = ''
1790 app['IssueTracker'] = ''
1791 app['SourceCode'] = ''
1792 app['CurrentVersionCode'] = 2147483647 # Java's Integer.MAX_VALUE
1793 if 'name' in apk and apk['name'] != '':
1794 app['Name'] = apk['name']
1796 logging.warning(_('{appid} does not have a name! Using package name instead.')
1797 .format(appid=apk['packageName']))
1798 app['Name'] = apk['packageName']
1799 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1800 yaml.dump(app, f, default_flow_style=False)
1801 logging.info(_("Generated skeleton metadata for {appid}").format(appid=apk['packageName']))
1806 start_timestamp = time.gmtime()
1811 global config, options
1813 # Parse command line...
1814 parser = ArgumentParser()
1815 common.setup_global_opts(parser)
1816 parser.add_argument("--create-key", action="store_true", default=False,
1817 help=_("Add a repo signing key to an unsigned repo"))
1818 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1819 help=_("Add skeleton metadata files for APKs that are missing them"))
1820 parser.add_argument("--delete-unknown", action="store_true", default=False,
1821 help=_("Delete APKs and/or OBBs without metadata from the repo"))
1822 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1823 help=_("Report on build data status"))
1824 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1825 help=_("Interactively ask about things that need updating."))
1826 parser.add_argument("-I", "--icons", action="store_true", default=False,
1827 help=_("Resize all the icons exceeding the max pixel size and exit"))
1828 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1829 help=_("Specify editor to use in interactive mode. Default " +
1830 "is {path}").format(path='/etc/alternatives/editor'))
1831 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1832 help=_("Update the wiki"))
1833 parser.add_argument("--pretty", action="store_true", default=False,
1834 help=_("Produce human-readable XML/JSON for index files"))
1835 parser.add_argument("--clean", action="store_true", default=False,
1836 help=_("Clean update - don't uses caches, reprocess all APKs"))
1837 parser.add_argument("--nosign", action="store_true", default=False,
1838 help=_("When configured for signed indexes, create only unsigned indexes at this stage"))
1839 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1840 help=_("Use date from APK instead of current time for newly added APKs"))
1841 parser.add_argument("--rename-apks", action="store_true", default=False,
1842 help=_("Rename APK files that do not match package.name_123.apk"))
1843 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1844 help=_("Include APKs that are signed with disabled algorithms like MD5"))
1845 metadata.add_metadata_arguments(parser)
1846 options = parser.parse_args()
1847 metadata.warnings_action = options.W
1849 config = common.read_config(options)
1851 if not ('jarsigner' in config and 'keytool' in config):
1852 raise FDroidException(_('Java JDK not found! Install in standard location or set java_paths!'))
1855 if config['archive_older'] != 0:
1856 repodirs.append('archive')
1857 if not os.path.exists('archive'):
1861 resize_all_icons(repodirs)
1864 if options.rename_apks:
1865 options.clean = True
1867 # check that icons exist now, rather than fail at the end of `fdroid update`
1868 for k in ['repo_icon', 'archive_icon']:
1870 if not os.path.exists(config[k]):
1871 logging.critical(_('{name} "{path}" does not exist! Correct it in config.py.')
1872 .format(name=k, path=config[k]))
1875 # if the user asks to create a keystore, do it now, reusing whatever it can
1876 if options.create_key:
1877 if os.path.exists(config['keystore']):
1878 logging.critical(_("Cowardily refusing to overwrite existing signing key setup!"))
1879 logging.critical("\t'" + config['keystore'] + "'")
1882 if 'repo_keyalias' not in config:
1883 config['repo_keyalias'] = socket.getfqdn()
1884 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1885 if 'keydname' not in config:
1886 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1887 common.write_to_config(config, 'keydname', config['keydname'])
1888 if 'keystore' not in config:
1889 config['keystore'] = common.default_config['keystore']
1890 common.write_to_config(config, 'keystore', config['keystore'])
1892 password = common.genpassword()
1893 if 'keystorepass' not in config:
1894 config['keystorepass'] = password
1895 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1896 if 'keypass' not in config:
1897 config['keypass'] = password
1898 common.write_to_config(config, 'keypass', config['keypass'])
1899 common.genkeystore(config)
1902 apps = metadata.read_metadata()
1904 # Generate a list of categories...
1906 for app in apps.values():
1907 categories.update(app.Categories)
1909 # Read known apks data (will be updated and written back when we've finished)
1910 knownapks = common.KnownApks()
1913 apkcache = get_cache()
1915 # Delete builds for disabled apps
1916 delete_disabled_builds(apps, apkcache, repodirs)
1918 # Scan all apks in the main repo
1919 apks, cachechanged = process_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1921 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1922 options.use_date_from_apk)
1923 cachechanged = cachechanged or fcachechanged
1926 if apk['packageName'] not in apps:
1927 if options.create_metadata:
1928 create_metadata_from_template(apk)
1929 apps = metadata.read_metadata()
1931 msg = _("{apkfilename} ({appid}) has no metadata!") \
1932 .format(apkfilename=apk['apkName'], appid=apk['packageName'])
1933 if options.delete_unknown:
1934 logging.warn(msg + '\n\t' + _("deleting: repo/{apkfilename}")
1935 .format(apkfilename=apk['apkName']))
1936 rmf = os.path.join(repodirs[0], apk['apkName'])
1937 if not os.path.exists(rmf):
1938 logging.error(_("Could not find {path} to remove it").format(path=rmf))
1942 logging.warn(msg + '\n\t' + _("Use `fdroid update -c` to create it."))
1944 copy_triple_t_store_metadata(apps)
1945 insert_obbs(repodirs[0], apps, apks)
1946 insert_localized_app_metadata(apps)
1947 translate_per_build_anti_features(apps, apks)
1949 # Scan the archive repo for apks as well
1950 if len(repodirs) > 1:
1951 archapks, cc = process_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1957 # Apply information from latest apks to the application and update dates
1958 apply_info_from_latest_apk(apps, apks + archapks)
1960 # Sort the app list by name, then the web site doesn't have to by default.
1961 # (we had to wait until we'd scanned the apks to do this, because mostly the
1962 # name comes from there!)
1963 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1965 # APKs are placed into multiple repos based on the app package, providing
1966 # per-app subscription feeds for nightly builds and things like it
1967 if config['per_app_repos']:
1968 add_apks_to_per_app_repos(repodirs[0], apks)
1969 for appid, app in apps.items():
1970 repodir = os.path.join(appid, 'fdroid', 'repo')
1972 appdict[appid] = app
1973 if os.path.isdir(repodir):
1974 index.make(appdict, [appid], apks, repodir, False)
1976 logging.info(_('Skipping index generation for {appid}').format(appid=appid))
1979 if len(repodirs) > 1:
1980 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1982 # Make the index for the main repo...
1983 index.make(apps, sortedids, apks, repodirs[0], False)
1984 make_categories_txt(repodirs[0], categories)
1986 # If there's an archive repo, make the index for it. We already scanned it
1988 if len(repodirs) > 1:
1989 index.make(apps, sortedids, archapks, repodirs[1], True)
1991 git_remote = config.get('binary_transparency_remote')
1992 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
1994 btlog.make_binary_transparency_log(repodirs)
1996 if config['update_stats']:
1997 # Update known apks info...
1998 knownapks.writeifchanged()
2000 # Generate latest apps data for widget
2001 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
2003 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
2005 appid = line.rstrip()
2006 data += appid + "\t"
2008 data += app.Name + "\t"
2009 if app.icon is not None:
2010 data += app.icon + "\t"
2011 data += app.License + "\n"
2012 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
2016 write_cache(apkcache)
2018 # Update the wiki...
2020 update_wiki(apps, sortedids, apks + archapks)
2022 logging.info(_("Finished"))
2025 if __name__ == "__main__":