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/>.
31 from datetime import datetime, timedelta
32 from argparse import ArgumentParser
35 from binascii import hexlify
42 from . import metadata
43 from .common import SdkToolsPopen
44 from .exception import BuildException, FDroidException
48 # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
49 UNSET_VERSION_CODE = -0x100000000
51 APK_NAME_PAT = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
52 APK_VERCODE_PAT = re.compile(".*versionCode='([0-9]*)'.*")
53 APK_VERNAME_PAT = re.compile(".*versionName='([^']*)'.*")
54 APK_LABEL_PAT = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
55 APK_ICON_PAT = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
56 APK_ICON_PAT_NODPI = re.compile(".*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 = ['640', '480', '320', '240', '160', '120']
63 screen_resolutions = {
75 all_screen_densities = ['0'] + screen_densities
77 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
78 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
80 ALLOWED_EXTENSIONS = ('png', 'jpg', 'jpeg')
81 GRAPHIC_NAMES = ('featureGraphic', 'icon', 'promoGraphic', 'tvBanner')
82 SCREENSHOT_DIRS = ('phoneScreenshots', 'sevenInchScreenshots',
83 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots')
86 def dpi_to_px(density):
87 return (int(density) * 48) / 160
91 return (int(px) * 160) / 48
94 def get_icon_dir(repodir, density):
96 return os.path.join(repodir, "icons")
97 return os.path.join(repodir, "icons-%s" % density)
100 def get_icon_dirs(repodir):
101 for density in screen_densities:
102 yield get_icon_dir(repodir, density)
105 def get_all_icon_dirs(repodir):
106 for density in all_screen_densities:
107 yield get_icon_dir(repodir, density)
110 def update_wiki(apps, sortedids, apks):
113 :param apps: fully populated list of all applications
114 :param apks: all apks, except...
116 logging.info("Updating wiki")
118 wikiredircat = 'App Redirects'
120 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
121 path=config['wiki_path'])
122 site.login(config['wiki_user'], config['wiki_password'])
124 generated_redirects = {}
126 for appid in sortedids:
127 app = metadata.App(apps[appid])
131 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
133 for af in app.AntiFeatures:
134 wikidata += '{{AntiFeature|' + af + '}}\n'
139 wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|changelog=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|license=%s|root=%s|author=%s|email=%s}}\n' % (
142 app.added.strftime('%Y-%m-%d') if app.added else '',
143 app.lastUpdated.strftime('%Y-%m-%d') if app.lastUpdated else '',
158 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
160 wikidata += app.Summary
161 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
163 wikidata += "=Description=\n"
164 wikidata += metadata.description_wiki(app.Description) + "\n"
166 wikidata += "=Maintainer Notes=\n"
167 if app.MaintainerNotes:
168 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
169 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)
171 # Get a list of all packages for this application...
173 gotcurrentver = False
177 if apk['packageName'] == appid:
178 if str(apk['versionCode']) == app.CurrentVersionCode:
181 # Include ones we can't build, as a special case...
182 for build in app.builds:
184 if build.versionCode == app.CurrentVersionCode:
186 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
187 apklist.append({'versionCode': int(build.versionCode),
188 'versionName': build.versionName,
189 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
194 if apk['versionCode'] == int(build.versionCode):
199 apklist.append({'versionCode': int(build.versionCode),
200 'versionName': build.versionName,
201 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
203 if app.CurrentVersionCode == '0':
205 # Sort with most recent first...
206 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
208 wikidata += "=Versions=\n"
209 if len(apklist) == 0:
210 wikidata += "We currently have no versions of this app available."
211 elif not gotcurrentver:
212 wikidata += "We don't have the current version of this app."
214 wikidata += "We have the current version of this app."
215 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
216 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
217 if len(app.NoSourceSince) > 0:
218 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
219 if len(app.CurrentVersion) > 0:
220 wikidata += "The current (recommended) version is " + app.CurrentVersion
221 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
224 wikidata += "==" + apk['versionName'] + "==\n"
226 if 'buildproblem' in apk:
227 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
230 wikidata += "This version is built and signed by "
232 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
234 wikidata += "the original developer.\n\n"
235 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
237 wikidata += '\n[[Category:' + wikicat + ']]\n'
238 if len(app.NoSourceSince) > 0:
239 wikidata += '\n[[Category:Apps missing source code]]\n'
240 if validapks == 0 and not app.Disabled:
241 wikidata += '\n[[Category:Apps with no packages]]\n'
242 if cantupdate and not app.Disabled:
243 wikidata += "\n[[Category:Apps we cannot update]]\n"
244 if buildfails and not app.Disabled:
245 wikidata += "\n[[Category:Apps with failing builds]]\n"
246 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
247 wikidata += '\n[[Category:Apps to Update]]\n'
249 wikidata += '\n[[Category:Apps that are disabled]]\n'
250 if app.UpdateCheckMode == 'None' and not app.Disabled:
251 wikidata += '\n[[Category:Apps with no update check]]\n'
252 for appcat in app.Categories:
253 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
255 # We can't have underscores in the page name, even if they're in
256 # the package ID, because MediaWiki messes with them...
257 pagename = appid.replace('_', ' ')
259 # Drop a trailing newline, because mediawiki is going to drop it anyway
260 # and it we don't we'll think the page has changed when it hasn't...
261 if wikidata.endswith('\n'):
262 wikidata = wikidata[:-1]
264 generated_pages[pagename] = wikidata
266 # Make a redirect from the name to the ID too, unless there's
267 # already an existing page with the name and it isn't a redirect.
269 apppagename = app.Name.replace('_', ' ')
270 apppagename = apppagename.replace('{', '')
271 apppagename = apppagename.replace('}', ' ')
272 apppagename = apppagename.replace(':', ' ')
273 apppagename = apppagename.replace('[', ' ')
274 apppagename = apppagename.replace(']', ' ')
275 # Drop double spaces caused mostly by replacing ':' above
276 apppagename = apppagename.replace(' ', ' ')
277 for expagename in site.allpages(prefix=apppagename,
278 filterredir='nonredirects',
280 if expagename == apppagename:
282 # Another reason not to make the redirect page is if the app name
283 # is the same as it's ID, because that will overwrite the real page
284 # with an redirect to itself! (Although it seems like an odd
285 # scenario this happens a lot, e.g. where there is metadata but no
286 # builds or binaries to extract a name from.
287 if apppagename == pagename:
290 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
292 for tcat, genp in [(wikicat, generated_pages),
293 (wikiredircat, generated_redirects)]:
294 catpages = site.Pages['Category:' + tcat]
296 for page in catpages:
297 existingpages.append(page.name)
298 if page.name in genp:
299 pagetxt = page.edit()
300 if pagetxt != genp[page.name]:
301 logging.debug("Updating modified page " + page.name)
302 page.save(genp[page.name], summary='Auto-updated')
304 logging.debug("Page " + page.name + " is unchanged")
306 logging.warn("Deleting page " + page.name)
307 page.delete('No longer published')
308 for pagename, text in genp.items():
309 logging.debug("Checking " + pagename)
310 if pagename not in existingpages:
311 logging.debug("Creating page " + pagename)
313 newpage = site.Pages[pagename]
314 newpage.save(text, summary='Auto-created')
315 except Exception as e:
316 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
318 # Purge server cache to ensure counts are up to date
319 site.pages['Repository Maintenance'].purge()
322 def delete_disabled_builds(apps, apkcache, repodirs):
323 """Delete disabled build outputs.
325 :param apps: list of all applications, as per metadata.read_metadata
326 :param apkcache: current apk cache information
327 :param repodirs: the repo directories to process
329 for appid, app in apps.items():
330 for build in app['builds']:
331 if not build.disable:
333 apkfilename = common.get_release_filename(app, build)
334 iconfilename = "%s.%s.png" % (
337 for repodir in repodirs:
339 os.path.join(repodir, apkfilename),
340 os.path.join(repodir, apkfilename + '.asc'),
341 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
343 for density in all_screen_densities:
344 repo_dir = get_icon_dir(repodir, density)
345 files.append(os.path.join(repo_dir, iconfilename))
348 if os.path.exists(f):
349 logging.info("Deleting disabled build output " + f)
351 if apkfilename in apkcache:
352 del apkcache[apkfilename]
355 def resize_icon(iconpath, density):
357 if not os.path.isfile(iconpath):
362 fp = open(iconpath, 'rb')
364 size = dpi_to_px(density)
366 if any(length > size for length in im.size):
368 im.thumbnail((size, size), Image.ANTIALIAS)
369 logging.debug("%s was too large at %s - new size is %s" % (
370 iconpath, oldsize, im.size))
371 im.save(iconpath, "PNG")
373 except Exception as e:
374 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
381 def resize_all_icons(repodirs):
382 """Resize all icons that exceed the max size
384 :param repodirs: the repo directories to process
386 for repodir in repodirs:
387 for density in screen_densities:
388 icon_dir = get_icon_dir(repodir, density)
389 icon_glob = os.path.join(icon_dir, '*.png')
390 for iconpath in glob.glob(icon_glob):
391 resize_icon(iconpath, density)
395 """ Get the signing certificate of an apk. To get the same md5 has that
396 Android gets, we encode the .RSA certificate in a specific format and pass
397 it hex-encoded to the md5 digest algorithm.
399 :param apkpath: path to the apk
400 :returns: A string containing the md5 of the signature of the apk or None
401 if an error occurred.
404 with zipfile.ZipFile(apkpath, 'r') as apk:
405 certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)]
408 logging.error("Found no signing certificates on %s" % apkpath)
411 logging.error("Found multiple signing certificates on %s" % apkpath)
414 cert = apk.read(certs[0])
416 cert_encoded = common.get_certificate(cert)
418 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
421 def get_cache_file():
422 return os.path.join('tmp', 'apkcache')
426 """Get the cached dict of the APK index
428 Gather information about all the apk files in the repo directory,
429 using cached data if possible. Some of the index operations take a
430 long time, like calculating the SHA-256 and verifying the APK
433 The cache is invalidated if the metadata version is different, or
434 the 'allow_disabled_algorithms' config/option is different. In
435 those cases, there is no easy way to know what has changed from
436 the cache, so just rerun the whole thing.
441 apkcachefile = get_cache_file()
442 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
443 if not options.clean and os.path.exists(apkcachefile):
444 with open(apkcachefile, 'rb') as cf:
445 apkcache = pickle.load(cf, encoding='utf-8')
446 if apkcache.get("METADATA_VERSION") != METADATA_VERSION \
447 or apkcache.get('allow_disabled_algorithms') != ada:
452 apkcache["METADATA_VERSION"] = METADATA_VERSION
453 apkcache['allow_disabled_algorithms'] = ada
458 def write_cache(apkcache):
459 apkcachefile = get_cache_file()
460 cache_path = os.path.dirname(apkcachefile)
461 if not os.path.exists(cache_path):
462 os.makedirs(cache_path)
463 with open(apkcachefile, 'wb') as cf:
464 pickle.dump(apkcache, cf)
467 def get_icon_bytes(apkzip, iconsrc):
468 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
470 return apkzip.read(iconsrc)
472 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
475 def sha256sum(filename):
476 '''Calculate the sha256 of the given file'''
477 sha = hashlib.sha256()
478 with open(filename, 'rb') as f:
484 return sha.hexdigest()
487 def has_known_vulnerability(filename):
488 """checks for known vulnerabilities in the APK
490 Checks OpenSSL .so files in the APK to see if they are a known vulnerable
491 version. Google also enforces this:
492 https://support.google.com/faqs/answer/6376725?hl=en
494 Checks whether there are more than one classes.dex or AndroidManifest.xml
495 files, which is invalid and an essential part of the "Master Key" attack.
497 http://www.saurik.com/id/17
500 # statically load this pattern
501 if not hasattr(has_known_vulnerability, "pattern"):
502 has_known_vulnerability.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
505 with zipfile.ZipFile(filename) as zf:
506 for name in zf.namelist():
507 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
510 chunk = lib.read(4096)
513 m = has_known_vulnerability.pattern.search(chunk)
515 version = m.group(1).decode('ascii')
516 if version.startswith('1.0.1') and version[5] >= 'r' \
517 or version.startswith('1.0.2') and version[5] >= 'f':
518 logging.debug('"%s" contains recent %s (%s)', filename, name, version)
520 logging.warning('"%s" contains outdated %s (%s)', filename, name, version)
523 elif name == 'AndroidManifest.xml' or name == 'classes.dex' or name.endswith('.so'):
524 if name in files_in_apk:
526 files_in_apk.add(name)
531 def insert_obbs(repodir, apps, apks):
532 """Scans the .obb files in a given repo directory and adds them to the
533 relevant APK instances. OBB files have versionCodes like APK
534 files, and they are loosely associated. If there is an OBB file
535 present, then any APK with the same or higher versionCode will use
536 that OBB file. There are two OBB types: main and patch, each APK
537 can only have only have one of each.
539 https://developer.android.com/google/play/expansion-files.html
541 :param repodir: repo directory to scan
542 :param apps: list of current, valid apps
543 :param apks: current information on all APKs
547 def obbWarnDelete(f, msg):
548 logging.warning(msg + f)
549 if options.delete_unknown:
550 logging.error("Deleting unknown file: " + f)
554 java_Integer_MIN_VALUE = -pow(2, 31)
555 currentPackageNames = apps.keys()
556 for f in glob.glob(os.path.join(repodir, '*.obb')):
557 obbfile = os.path.basename(f)
558 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
559 chunks = obbfile.split('.')
560 if chunks[0] != 'main' and chunks[0] != 'patch':
561 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
563 if not re.match(r'^-?[0-9]+$', chunks[1]):
564 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
566 versionCode = int(chunks[1])
567 packagename = ".".join(chunks[2:-1])
569 highestVersionCode = java_Integer_MIN_VALUE
570 if packagename not in currentPackageNames:
571 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
574 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
575 highestVersionCode = apk['versionCode']
576 if versionCode > highestVersionCode:
577 obbWarnDelete(f, 'OBB file has newer versionCode(' + str(versionCode)
578 + ') than any APK: ')
580 obbsha256 = sha256sum(f)
581 obbs.append((packagename, versionCode, obbfile, obbsha256))
584 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
585 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
586 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
587 apk['obbMainFile'] = obbfile
588 apk['obbMainFileSha256'] = obbsha256
589 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
590 apk['obbPatchFile'] = obbfile
591 apk['obbPatchFileSha256'] = obbsha256
592 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
596 def translate_per_build_anti_features(apps, apks):
597 """Grab the anti-features list from the build metadata
599 For most Anti-Features, they are really most applicable per-APK,
600 not for an app. An app can fix a vulnerability, add/remove
601 tracking, etc. This reads the 'antifeatures' list from the Build
602 entries in the fdroiddata metadata file, then transforms it into
603 the 'antiFeatures' list of unique items for the index.
605 The field key is all lower case in the metadata file to match the
606 rest of the Build fields. It is 'antiFeatures' camel case in the
607 implementation, index, and fdroidclient since it is translated
608 from the build 'antifeatures' field, not directly included.
612 antiFeatures = dict()
613 for packageName, app in apps.items():
615 for build in app['builds']:
616 afl = build.get('antifeatures')
618 d[int(build.versionCode)] = afl
620 antiFeatures[packageName] = d
623 d = antiFeatures.get(apk['packageName'])
625 afl = d.get(apk['versionCode'])
627 apk['antiFeatures'].update(afl)
630 def _get_localized_dict(app, locale):
631 '''get the dict to add localized store metadata to'''
632 if 'localized' not in app:
633 app['localized'] = collections.OrderedDict()
634 if locale not in app['localized']:
635 app['localized'][locale] = collections.OrderedDict()
636 return app['localized'][locale]
639 def _set_localized_text_entry(app, locale, key, f):
640 limit = config['char_limits'][key]
641 localized = _get_localized_dict(app, locale)
643 text = fp.read()[:limit]
645 localized[key] = text
648 def _set_author_entry(app, key, f):
649 limit = config['char_limits']['author']
651 text = fp.read()[:limit]
656 def copy_triple_t_store_metadata(apps):
657 """Include store metadata from the app's source repo
659 The Triple-T Gradle Play Publisher is a plugin that has a standard
660 file layout for all of the metadata and graphics that the Google
661 Play Store accepts. Since F-Droid has the git repo, it can just
662 pluck those files directly. This method reads any text files into
663 the app dict, then copies any graphics into the fdroid repo
666 This needs to be run before insert_localized_app_metadata() so that
667 the graphics files that are copied into the fdroid repo get
670 https://github.com/Triple-T/gradle-play-publisher#upload-images
671 https://github.com/Triple-T/gradle-play-publisher#play-store-metadata
675 if not os.path.isdir('build'):
676 return # nothing to do
678 for packageName, app in apps.items():
679 for d in glob.glob(os.path.join('build', packageName, '*', 'src', '*', 'play')):
680 logging.debug('Triple-T Gradle Play Publisher: ' + d)
681 for root, dirs, files in os.walk(d):
682 segments = root.split('/')
683 locale = segments[-2]
685 if f == 'fulldescription':
686 _set_localized_text_entry(app, locale, 'description',
687 os.path.join(root, f))
689 elif f == 'shortdescription':
690 _set_localized_text_entry(app, locale, 'summary',
691 os.path.join(root, f))
694 _set_localized_text_entry(app, locale, 'name',
695 os.path.join(root, f))
698 _set_localized_text_entry(app, locale, 'video',
699 os.path.join(root, f))
701 elif f == 'whatsnew':
702 _set_localized_text_entry(app, segments[-1], 'whatsNew',
703 os.path.join(root, f))
705 elif f == 'contactEmail':
706 _set_author_entry(app, 'authorEmail', os.path.join(root, f))
708 elif f == 'contactPhone':
709 _set_author_entry(app, 'authorPhone', os.path.join(root, f))
711 elif f == 'contactWebsite':
712 _set_author_entry(app, 'authorWebSite', os.path.join(root, f))
715 base, extension = common.get_extension(f)
716 dirname = os.path.basename(root)
717 if dirname in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
718 if segments[-2] == 'listing':
719 locale = segments[-3]
721 locale = segments[-2]
722 destdir = os.path.join('repo', packageName, locale)
723 os.makedirs(destdir, mode=0o755, exist_ok=True)
724 sourcefile = os.path.join(root, f)
725 destfile = os.path.join(destdir, dirname + '.' + extension)
726 logging.debug('copying ' + sourcefile + ' ' + destfile)
727 shutil.copy(sourcefile, destfile)
730 def insert_localized_app_metadata(apps):
731 """scans standard locations for graphics and localized text
733 Scans for localized description files, store graphics, and
734 screenshot PNG files in statically defined screenshots directory
735 and adds them to the app metadata. The screenshots and graphic
736 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
737 and must be in the following layout:
738 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
740 repo/packageName/locale/featureGraphic.png
741 repo/packageName/locale/phoneScreenshots/1.png
742 repo/packageName/locale/phoneScreenshots/2.png
744 The changelog files must be text files named with the versionCode
745 ending with ".txt" and must be in the following layout:
746 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
748 repo/packageName/locale/changelogs/12345.txt
750 This will scan the each app's source repo then the metadata/ dir
751 for these standard locations of changelog files. If it finds
752 them, they will be added to the dict of all packages, with the
753 versions in the metadata/ folder taking precendence over the what
754 is in the app's source repo.
756 Where "packageName" is the app's packageName and "locale" is the locale
757 of the graphics, e.g. what language they are in, using the IETF RFC5646
758 format (en-US, fr-CA, es-MX, etc).
760 This will also scan the app's git for a fastlane folder, and the
761 metadata/ folder and the apps' source repos for standard locations
762 of graphic and screenshot files. If it finds them, it will copy
763 them into the repo. The fastlane files follow this pattern:
764 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
768 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
769 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
770 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
772 for srcd in sorted(sourcedirs):
773 if not os.path.isdir(srcd):
775 for root, dirs, files in os.walk(srcd):
776 segments = root.split('/')
777 packageName = segments[1]
778 if packageName not in apps:
779 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
781 locale = segments[-1]
782 destdir = os.path.join('repo', packageName, locale)
784 if f in ('description.txt', 'full_description.txt'):
785 _set_localized_text_entry(apps[packageName], locale, 'description',
786 os.path.join(root, f))
788 elif f in ('summary.txt', 'short_description.txt'):
789 _set_localized_text_entry(apps[packageName], locale, 'summary',
790 os.path.join(root, f))
792 elif f in ('name.txt', 'title.txt'):
793 _set_localized_text_entry(apps[packageName], locale, 'name',
794 os.path.join(root, f))
796 elif f == 'video.txt':
797 _set_localized_text_entry(apps[packageName], locale, 'video',
798 os.path.join(root, f))
800 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
801 locale = segments[-2]
802 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
803 os.path.join(root, f))
806 base, extension = common.get_extension(f)
807 if locale == 'images':
808 locale = segments[-2]
809 destdir = os.path.join('repo', packageName, locale)
810 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
811 os.makedirs(destdir, mode=0o755, exist_ok=True)
812 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
813 shutil.copy(os.path.join(root, f), destdir)
815 if d in SCREENSHOT_DIRS:
816 for f in glob.glob(os.path.join(root, d, '*.*')):
817 _, extension = common.get_extension(f)
818 if extension in ALLOWED_EXTENSIONS:
819 screenshotdestdir = os.path.join(destdir, d)
820 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
821 logging.debug('copying ' + f + ' ' + screenshotdestdir)
822 shutil.copy(f, screenshotdestdir)
824 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
826 if not os.path.isdir(d):
828 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
829 if not os.path.isfile(f):
831 segments = f.split('/')
832 packageName = segments[1]
834 screenshotdir = segments[3]
835 filename = os.path.basename(f)
836 base, extension = common.get_extension(filename)
838 if packageName not in apps:
839 logging.warning('Found "%s" graphic without metadata for app "%s"!'
840 % (filename, packageName))
842 graphics = _get_localized_dict(apps[packageName], locale)
844 if extension not in ALLOWED_EXTENSIONS:
845 logging.warning('Only PNG and JPEG are supported for graphics, found: ' + f)
846 elif base in GRAPHIC_NAMES:
847 # there can only be zero or one of these per locale
848 graphics[base] = filename
849 elif screenshotdir in SCREENSHOT_DIRS:
850 # there can any number of these per locale
851 logging.debug('adding to ' + screenshotdir + ': ' + f)
852 if screenshotdir not in graphics:
853 graphics[screenshotdir] = []
854 graphics[screenshotdir].append(filename)
856 logging.warning('Unsupported graphics file found: ' + f)
859 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
860 """Scan a repo for all files with an extension except APK/OBB
862 :param apkcache: current cached info about all repo files
863 :param repodir: repo directory to scan
864 :param knownapks: list of all known files, as per metadata.read_metadata
865 :param use_date_from_file: use date from file (instead of current date)
866 for newly added files
871 repodir = repodir.encode('utf-8')
872 for name in os.listdir(repodir):
873 file_extension = common.get_file_extension(name)
874 if file_extension == 'apk' or file_extension == 'obb':
876 filename = os.path.join(repodir, name)
877 name_utf8 = name.decode('utf-8')
878 if filename.endswith(b'_src.tar.gz'):
879 logging.debug('skipping source tarball: ' + filename.decode('utf-8'))
881 if not common.is_repo_file(filename):
883 stat = os.stat(filename)
884 if stat.st_size == 0:
885 raise FDroidException(filename + ' is zero size!')
887 shasum = sha256sum(filename)
890 repo_file = apkcache[name]
891 # added time is cached as tuple but used here as datetime instance
892 if 'added' in repo_file:
893 a = repo_file['added']
894 if isinstance(a, datetime):
895 repo_file['added'] = a
897 repo_file['added'] = datetime(*a[:6])
898 if repo_file.get('hash') == shasum:
899 logging.debug("Reading " + name_utf8 + " from cache")
902 logging.debug("Ignoring stale cache data for " + name)
905 logging.debug("Processing " + name_utf8)
906 repo_file = collections.OrderedDict()
907 repo_file['name'] = os.path.splitext(name_utf8)[0]
908 # TODO rename apkname globally to something more generic
909 repo_file['apkName'] = name_utf8
910 repo_file['hash'] = shasum
911 repo_file['hashType'] = 'sha256'
912 repo_file['versionCode'] = 0
913 repo_file['versionName'] = shasum
914 # the static ID is the SHA256 unless it is set in the metadata
915 repo_file['packageName'] = shasum
917 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
919 repo_file['packageName'] = m.group(1)
920 repo_file['versionCode'] = int(m.group(2))
921 srcfilename = name + b'_src.tar.gz'
922 if os.path.exists(os.path.join(repodir, srcfilename)):
923 repo_file['srcname'] = srcfilename.decode('utf-8')
924 repo_file['size'] = stat.st_size
926 apkcache[name] = repo_file
929 if use_date_from_file:
930 timestamp = stat.st_ctime
931 default_date_param = datetime.fromtimestamp(timestamp).utctimetuple()
933 default_date_param = None
935 # Record in knownapks, getting the added date at the same time..
936 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
937 default_date=default_date_param)
939 repo_file['added'] = added
941 repo_files.append(repo_file)
943 return repo_files, cachechanged
946 def scan_apk(apk_file):
948 Scans an APK file and returns dictionary with metadata of the APK.
950 Attention: This does *not* verify that the APK signature is correct.
952 :param apk_file: The (ideally absolute) path to the APK file
953 :raises BuildException
954 :return A dict containing APK metadata
957 'hash': sha256sum(apk_file),
958 'hashType': 'sha256',
959 'uses-permission': [],
960 'uses-permission-sdk-23': [],
964 'antiFeatures': set(),
967 if SdkToolsPopen(['aapt', 'version'], output=False):
968 scan_apk_aapt(apk, apk_file)
970 scan_apk_androguard(apk, apk_file)
973 logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
974 apk['sig'] = getsig(apk_file)
976 raise BuildException("Failed to get apk signature")
978 # Get size of the APK
979 apk['size'] = os.path.getsize(apk_file)
981 if 'minSdkVersion' not in apk:
982 logging.warning("No SDK version information found in {0}".format(apk_file))
983 apk['minSdkVersion'] = 1
985 # Check for known vulnerabilities
986 if has_known_vulnerability(apk_file):
987 apk['antiFeatures'].add('KnownVuln')
992 def scan_apk_aapt(apk, apkfile):
993 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
994 if p.returncode != 0:
995 if options.delete_unknown:
996 if os.path.exists(apkfile):
997 logging.error("Failed to get apk information, deleting " + apkfile)
1000 logging.error("Could not find {0} to remove it".format(apkfile))
1002 logging.error("Failed to get apk information, skipping " + apkfile)
1003 raise BuildException("Invalid APK")
1004 for line in p.output.splitlines():
1005 if line.startswith("package:"):
1007 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
1008 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
1009 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
1010 except Exception as e:
1011 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
1012 elif line.startswith("application:"):
1013 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1014 # Keep path to non-dpi icon in case we need it
1015 match = re.match(APK_ICON_PAT_NODPI, line)
1017 apk['icons_src']['-1'] = match.group(1)
1018 elif line.startswith("launchable-activity:"):
1019 # Only use launchable-activity as fallback to application
1021 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1022 if '-1' not in apk['icons_src']:
1023 match = re.match(APK_ICON_PAT_NODPI, line)
1025 apk['icons_src']['-1'] = match.group(1)
1026 elif line.startswith("application-icon-"):
1027 match = re.match(APK_ICON_PAT, line)
1029 density = match.group(1)
1030 path = match.group(2)
1031 apk['icons_src'][density] = path
1032 elif line.startswith("sdkVersion:"):
1033 m = re.match(APK_SDK_VERSION_PAT, line)
1035 logging.error(line.replace('sdkVersion:', '')
1036 + ' is not a valid minSdkVersion!')
1038 apk['minSdkVersion'] = m.group(1)
1039 # if target not set, default to min
1040 if 'targetSdkVersion' not in apk:
1041 apk['targetSdkVersion'] = m.group(1)
1042 elif line.startswith("targetSdkVersion:"):
1043 m = re.match(APK_SDK_VERSION_PAT, line)
1045 logging.error(line.replace('targetSdkVersion:', '')
1046 + ' is not a valid targetSdkVersion!')
1048 apk['targetSdkVersion'] = m.group(1)
1049 elif line.startswith("maxSdkVersion:"):
1050 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
1051 elif line.startswith("native-code:"):
1052 apk['nativecode'] = []
1053 for arch in line[13:].split(' '):
1054 apk['nativecode'].append(arch[1:-1])
1055 elif line.startswith('uses-permission:'):
1056 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1057 if perm_match['maxSdkVersion']:
1058 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1059 permission = UsesPermission(
1061 perm_match['maxSdkVersion']
1064 apk['uses-permission'].append(permission)
1065 elif line.startswith('uses-permission-sdk-23:'):
1066 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1067 if perm_match['maxSdkVersion']:
1068 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1069 permission_sdk_23 = UsesPermissionSdk23(
1071 perm_match['maxSdkVersion']
1074 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1076 elif line.startswith('uses-feature:'):
1077 feature = re.match(APK_FEATURE_PAT, line).group(1)
1078 # Filter out this, it's only added with the latest SDK tools and
1079 # causes problems for lots of apps.
1080 if feature != "android.hardware.screen.portrait" \
1081 and feature != "android.hardware.screen.landscape":
1082 if feature.startswith("android.feature."):
1083 feature = feature[16:]
1084 apk['features'].add(feature)
1087 def scan_apk_androguard(apk, apkfile):
1089 from androguard.core.bytecodes.apk import APK
1090 apkobject = APK(apkfile)
1091 if apkobject.is_valid_APK():
1092 arsc = apkobject.get_android_resources()
1094 if options.delete_unknown:
1095 if os.path.exists(apkfile):
1096 logging.error("Failed to get apk information, deleting " + apkfile)
1099 logging.error("Could not find {0} to remove it".format(apkfile))
1101 logging.error("Failed to get apk information, skipping " + apkfile)
1102 raise BuildException("Invaild APK")
1104 raise FDroidException("androguard library is not installed and aapt not present")
1105 except FileNotFoundError:
1106 logging.error("Could not open apk file for analysis")
1107 raise BuildException("Invalid APK")
1109 apk['packageName'] = apkobject.get_package()
1110 apk['versionCode'] = int(apkobject.get_androidversion_code())
1111 apk['versionName'] = apkobject.get_androidversion_name()
1112 if apk['versionName'][0] == "@":
1113 version_id = int(apk['versionName'].replace("@", "0x"), 16)
1114 version_id = arsc.get_id(apk['packageName'], version_id)[1]
1115 apk['versionName'] = arsc.get_string(apk['packageName'], version_id)[1]
1116 apk['name'] = apkobject.get_app_name()
1118 if apkobject.get_max_sdk_version() is not None:
1119 apk['maxSdkVersion'] = apkobject.get_max_sdk_version()
1120 apk['minSdkVersion'] = apkobject.get_min_sdk_version()
1121 apk['targetSdkVersion'] = apkobject.get_target_sdk_version()
1123 icon_id = int(apkobject.get_element("application", "icon").replace("@", "0x"), 16)
1124 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1126 density_re = re.compile("^res/(.*)/" + icon_name + ".*$")
1128 for file in apkobject.get_files():
1129 d_re = density_re.match(file)
1131 folder = d_re.group(1).split('-')
1133 resolution = folder[1]
1136 density = screen_resolutions[resolution]
1137 apk['icons_src'][density] = d_re.group(0)
1139 if apk['icons_src'].get('-1') is None:
1140 apk['icons_src']['-1'] = apk['icons_src']['160']
1142 arch_re = re.compile("^lib/(.*)/.*$")
1143 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1145 apk['nativecode'] = []
1146 apk['nativecode'].extend(sorted(list(arch)))
1148 xml = apkobject.get_android_manifest_xml()
1150 for item in xml.getElementsByTagName('uses-permission'):
1151 name = str(item.getAttribute("android:name"))
1152 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1153 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1154 permission = UsesPermission(
1158 apk['uses-permission'].append(permission)
1160 for item in xml.getElementsByTagName('uses-permission-sdk-23'):
1161 name = str(item.getAttribute("android:name"))
1162 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1163 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1164 permission_sdk_23 = UsesPermissionSdk23(
1168 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1170 for item in xml.getElementsByTagName('uses-feature'):
1171 feature = str(item.getAttribute("android:name"))
1172 if feature != "android.hardware.screen.portrait" \
1173 and feature != "android.hardware.screen.landscape":
1174 if feature.startswith("android.feature."):
1175 feature = feature[16:]
1176 apk['features'].append(feature)
1179 def process_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1180 allow_disabled_algorithms=False, archive_bad_sig=False):
1181 """Processes the apk with the given filename in the given repo directory.
1183 This also extracts the icons.
1185 :param apkcache: current apk cache information
1186 :param apkfilename: the filename of the apk to scan
1187 :param repodir: repo directory to scan
1188 :param knownapks: known apks info
1189 :param use_date_from_apk: use date from APK (instead of current date)
1190 for newly added APKs
1191 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1192 disabled algorithms in the signature (e.g. MD5)
1193 :param archive_bad_sig: move APKs with a bad signature to the archive
1194 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1195 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1198 if ' ' in apkfilename:
1199 if options.rename_apks:
1200 newfilename = apkfilename.replace(' ', '_')
1201 os.rename(os.path.join(repodir, apkfilename),
1202 os.path.join(repodir, newfilename))
1203 apkfilename = newfilename
1205 logging.critical("Spaces in filenames are not allowed.")
1206 return True, None, False
1209 apkfile = os.path.join(repodir, apkfilename)
1211 cachechanged = False
1213 if apkfilename in apkcache:
1214 apk = apkcache[apkfilename]
1215 if apk.get('hash') == sha256sum(apkfile):
1216 logging.debug("Reading " + apkfilename + " from cache")
1219 logging.debug("Ignoring stale cache data for " + apkfilename)
1222 logging.debug("Processing " + apkfilename)
1225 apk = scan_apk(apkfile)
1226 except BuildException:
1227 logging.warning('Skipping "%s" with invalid signature!', apkfilename)
1228 return True, None, False
1230 # Check for debuggable apks...
1231 if common.isApkAndDebuggable(apkfile):
1232 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1234 if options.rename_apks:
1235 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1236 std_short_name = os.path.join(repodir, n)
1237 if apkfile != std_short_name:
1238 if os.path.exists(std_short_name):
1239 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1240 if apkfile != std_long_name:
1241 if os.path.exists(std_long_name):
1242 dupdir = os.path.join('duplicates', repodir)
1243 if not os.path.isdir(dupdir):
1244 os.makedirs(dupdir, exist_ok=True)
1245 dupfile = os.path.join('duplicates', std_long_name)
1246 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1247 os.rename(apkfile, dupfile)
1248 return True, None, False
1250 os.rename(apkfile, std_long_name)
1251 apkfile = std_long_name
1253 os.rename(apkfile, std_short_name)
1254 apkfile = std_short_name
1255 apkfilename = apkfile[len(repodir) + 1:]
1257 apk['apkName'] = apkfilename
1258 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1259 if os.path.exists(os.path.join(repodir, srcfilename)):
1260 apk['srcname'] = srcfilename
1262 # verify the jar signature is correct, allow deprecated
1263 # algorithms only if the APK is in the archive.
1265 if not common.verify_apk_signature(apkfile):
1266 if repodir == 'archive' or allow_disabled_algorithms:
1267 if common.verify_old_apk_signature(apkfile):
1268 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1276 logging.warning('Archiving "' + apkfilename + '" with invalid signature!')
1277 move_apk_between_sections(repodir, 'archive', apk)
1279 logging.warning('Skipping "' + apkfilename + '" with invalid signature!')
1280 return True, None, False
1282 apkzip = zipfile.ZipFile(apkfile, 'r')
1284 # if an APK has files newer than the system time, suggest updating
1285 # the system clock. This is useful for offline systems, used for
1286 # signing, which do not have another source of clock sync info. It
1287 # has to be more than 24 hours newer because ZIP/APK files do not
1288 # store timezone info
1289 manifest = apkzip.getinfo('AndroidManifest.xml')
1290 if manifest.date_time[1] == 0: # month can't be zero
1291 logging.debug('AndroidManifest.xml has no date')
1293 dt_obj = datetime(*manifest.date_time)
1294 checkdt = dt_obj - timedelta(1)
1295 if datetime.today() < checkdt:
1296 logging.warning('System clock is older than manifest in: '
1298 + '\nSet clock to that time using:\n'
1299 + 'sudo date -s "' + str(dt_obj) + '"')
1301 # extract icons from APK zip file
1302 iconfilename = "%s.%s.png" % (apk['packageName'], apk['versionCode'])
1304 empty_densities = extract_apk_icons(iconfilename, apk, apkzip, repodir)
1306 apkzip.close() # ensure that APK zip file gets closed
1308 # resize existing icons for densities missing in the APK
1309 fill_missing_icon_densities(empty_densities, iconfilename, apk, repodir)
1311 if use_date_from_apk and manifest.date_time[1] != 0:
1312 default_date_param = datetime(*manifest.date_time)
1314 default_date_param = None
1316 # Record in known apks, getting the added date at the same time..
1317 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1318 default_date=default_date_param)
1320 apk['added'] = added
1322 apkcache[apkfilename] = apk
1325 return False, apk, cachechanged
1328 def process_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1329 """Processes the apks in the given repo directory.
1331 This also extracts the icons.
1333 :param apkcache: current apk cache information
1334 :param repodir: repo directory to scan
1335 :param knownapks: known apks info
1336 :param use_date_from_apk: use date from APK (instead of current date)
1337 for newly added APKs
1338 :returns: (apks, cachechanged) where apks is a list of apk information,
1339 and cachechanged is True if the apkcache got changed.
1342 cachechanged = False
1344 for icon_dir in get_all_icon_dirs(repodir):
1345 if os.path.exists(icon_dir):
1347 shutil.rmtree(icon_dir)
1348 os.makedirs(icon_dir)
1350 os.makedirs(icon_dir)
1353 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1354 apkfilename = apkfile[len(repodir) + 1:]
1355 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1356 (skip, apk, cachethis) = process_apk(apkcache, apkfilename, repodir, knownapks,
1357 use_date_from_apk, ada, True)
1361 cachechanged = cachechanged or cachethis
1363 return apks, cachechanged
1366 def extract_apk_icons(icon_filename, apk, apk_zip, repo_dir):
1368 Extracts icons from the given APK zip in various densities,
1369 saves them into given repo directory
1370 and stores their names in the APK metadata dictionary.
1372 :param icon_filename: A string representing the icon's file name
1373 :param apk: A populated dictionary containing APK metadata.
1374 Needs to have 'icons_src' key
1375 :param apk_zip: An opened zipfile.ZipFile of the APK file
1376 :param repo_dir: The directory of the APK's repository
1377 :return: A list of icon densities that are missing
1379 empty_densities = []
1380 for density in screen_densities:
1381 if density not in apk['icons_src']:
1382 empty_densities.append(density)
1384 icon_src = apk['icons_src'][density]
1385 icon_dir = get_icon_dir(repo_dir, density)
1386 icon_dest = os.path.join(icon_dir, icon_filename)
1388 # Extract the icon files per density
1390 with open(icon_dest, 'wb') as f:
1391 f.write(get_icon_bytes(apk_zip, icon_src))
1392 apk['icons'][density] = icon_filename
1393 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1394 logging.warning("Error retrieving icon file: %s %s", icon_dest, e)
1395 del apk['icons_src'][density]
1396 empty_densities.append(density)
1398 if '-1' in apk['icons_src']:
1399 icon_src = apk['icons_src']['-1']
1400 icon_path = os.path.join(get_icon_dir(repo_dir, '0'), icon_filename)
1401 with open(icon_path, 'wb') as f:
1402 f.write(get_icon_bytes(apk_zip, icon_src))
1404 im = Image.open(icon_path)
1405 dpi = px_to_dpi(im.size[0])
1406 for density in screen_densities:
1407 if density in apk['icons']:
1409 if density == screen_densities[-1] or dpi >= int(density):
1410 apk['icons'][density] = icon_filename
1411 shutil.move(icon_path,
1412 os.path.join(get_icon_dir(repo_dir, density), icon_filename))
1413 empty_densities.remove(density)
1415 except Exception as e:
1416 logging.warning("Failed reading {0} - {1}".format(icon_path, e))
1419 apk['icon'] = icon_filename
1421 return empty_densities
1424 def fill_missing_icon_densities(empty_densities, icon_filename, apk, repo_dir):
1426 Resize existing icons for densities missing in the APK to ensure all densities are available
1428 :param empty_densities: A list of icon densities that are missing
1429 :param icon_filename: A string representing the icon's file name
1430 :param apk: A populated dictionary containing APK metadata. Needs to have 'icons' key
1431 :param repo_dir: The directory of the APK's repository
1433 # First try resizing down to not lose quality
1435 for density in screen_densities:
1436 if density not in empty_densities:
1437 last_density = density
1439 if last_density is None:
1441 logging.debug("Density %s not available, resizing down from %s", density, last_density)
1443 last_icon_path = os.path.join(get_icon_dir(repo_dir, last_density), icon_filename)
1444 icon_path = os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1447 fp = open(last_icon_path, 'rb')
1450 size = dpi_to_px(density)
1452 im.thumbnail((size, size), Image.ANTIALIAS)
1453 im.save(icon_path, "PNG")
1454 empty_densities.remove(density)
1455 except Exception as e:
1456 logging.warning("Invalid image file at %s: %s", last_icon_path, e)
1461 # Then just copy from the highest resolution available
1463 for density in reversed(screen_densities):
1464 if density not in empty_densities:
1465 last_density = density
1468 if last_density is None:
1472 os.path.join(get_icon_dir(repo_dir, last_density), icon_filename),
1473 os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1475 empty_densities.remove(density)
1477 for density in screen_densities:
1478 icon_dir = get_icon_dir(repo_dir, density)
1479 icon_dest = os.path.join(icon_dir, icon_filename)
1480 resize_icon(icon_dest, density)
1482 # Copy from icons-mdpi to icons since mdpi is the baseline density
1483 baseline = os.path.join(get_icon_dir(repo_dir, '160'), icon_filename)
1484 if os.path.isfile(baseline):
1485 apk['icons']['0'] = icon_filename
1486 shutil.copyfile(baseline, os.path.join(get_icon_dir(repo_dir, '0'), icon_filename))
1489 def apply_info_from_latest_apk(apps, apks):
1491 Some information from the apks needs to be applied up to the application level.
1492 When doing this, we use the info from the most recent version's apk.
1493 We deal with figuring out when the app was added and last updated at the same time.
1495 for appid, app in apps.items():
1496 bestver = UNSET_VERSION_CODE
1498 if apk['packageName'] == appid:
1499 if apk['versionCode'] > bestver:
1500 bestver = apk['versionCode']
1504 if not app.added or apk['added'] < app.added:
1505 app.added = apk['added']
1506 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1507 app.lastUpdated = apk['added']
1510 logging.debug("Don't know when " + appid + " was added")
1511 if not app.lastUpdated:
1512 logging.debug("Don't know when " + appid + " was last updated")
1514 if bestver == UNSET_VERSION_CODE:
1516 if app.Name is None:
1517 app.Name = app.AutoName or appid
1519 logging.debug("Application " + appid + " has no packages")
1521 if app.Name is None:
1522 app.Name = bestapk['name']
1523 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1524 if app.CurrentVersionCode is None:
1525 app.CurrentVersionCode = str(bestver)
1528 def make_categories_txt(repodir, categories):
1529 '''Write a category list in the repo to allow quick access'''
1531 for cat in sorted(categories):
1532 catdata += cat + '\n'
1533 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1537 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1539 def filter_apk_list_sorted(apk_list):
1541 for apk in apk_list:
1542 if apk['packageName'] == appid:
1545 # Sort the apk list by version code. First is highest/newest.
1546 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1548 for appid, app in apps.items():
1550 if app.ArchivePolicy:
1551 keepversions = int(app.ArchivePolicy[:-9])
1553 keepversions = defaultkeepversions
1555 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1556 .format(appid, len(apks), keepversions, len(archapks)))
1558 current_app_apks = filter_apk_list_sorted(apks)
1559 if len(current_app_apks) > keepversions:
1560 # Move back the ones we don't want.
1561 for apk in current_app_apks[keepversions:]:
1562 move_apk_between_sections(repodir, archivedir, apk)
1563 archapks.append(apk)
1566 current_app_archapks = filter_apk_list_sorted(archapks)
1567 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1569 # Move forward the ones we want again, except DisableAlgorithm
1570 for apk in current_app_archapks:
1571 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1572 move_apk_between_sections(archivedir, repodir, apk)
1573 archapks.remove(apk)
1576 if kept == keepversions:
1580 def move_apk_between_sections(from_dir, to_dir, apk):
1581 """move an APK from repo to archive or vice versa"""
1583 def _move_file(from_dir, to_dir, filename, ignore_missing):
1584 from_path = os.path.join(from_dir, filename)
1585 if ignore_missing and not os.path.exists(from_path):
1587 to_path = os.path.join(to_dir, filename)
1588 if not os.path.exists(to_dir):
1590 shutil.move(from_path, to_path)
1592 if from_dir == to_dir:
1595 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1596 _move_file(from_dir, to_dir, apk['apkName'], False)
1597 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1598 for density in all_screen_densities:
1599 from_icon_dir = get_icon_dir(from_dir, density)
1600 to_icon_dir = get_icon_dir(to_dir, density)
1601 if density not in apk['icons']:
1603 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1604 if 'srcname' in apk:
1605 _move_file(from_dir, to_dir, apk['srcname'], False)
1608 def add_apks_to_per_app_repos(repodir, apks):
1609 apks_per_app = dict()
1611 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1612 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1613 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1614 apks_per_app[apk['packageName']] = apk
1616 if not os.path.exists(apk['per_app_icons']):
1617 logging.info('Adding new repo for only ' + apk['packageName'])
1618 os.makedirs(apk['per_app_icons'])
1620 apkpath = os.path.join(repodir, apk['apkName'])
1621 shutil.copy(apkpath, apk['per_app_repo'])
1622 apksigpath = apkpath + '.sig'
1623 if os.path.exists(apksigpath):
1624 shutil.copy(apksigpath, apk['per_app_repo'])
1625 apkascpath = apkpath + '.asc'
1626 if os.path.exists(apkascpath):
1627 shutil.copy(apkascpath, apk['per_app_repo'])
1636 global config, options
1638 # Parse command line...
1639 parser = ArgumentParser()
1640 common.setup_global_opts(parser)
1641 parser.add_argument("--create-key", action="store_true", default=False,
1642 help="Create a repo signing key in a keystore")
1643 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1644 help="Create skeleton metadata files that are missing")
1645 parser.add_argument("--delete-unknown", action="store_true", default=False,
1646 help="Delete APKs and/or OBBs without metadata from the repo")
1647 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1648 help="Report on build data status")
1649 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1650 help="Interactively ask about things that need updating.")
1651 parser.add_argument("-I", "--icons", action="store_true", default=False,
1652 help="Resize all the icons exceeding the max pixel size and exit")
1653 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1654 help="Specify editor to use in interactive mode. Default " +
1655 "is /etc/alternatives/editor")
1656 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1657 help="Update the wiki")
1658 parser.add_argument("--pretty", action="store_true", default=False,
1659 help="Produce human-readable index.xml")
1660 parser.add_argument("--clean", action="store_true", default=False,
1661 help="Clean update - don't uses caches, reprocess all apks")
1662 parser.add_argument("--nosign", action="store_true", default=False,
1663 help="When configured for signed indexes, create only unsigned indexes at this stage")
1664 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1665 help="Use date from apk instead of current time for newly added apks")
1666 parser.add_argument("--rename-apks", action="store_true", default=False,
1667 help="Rename APK files that do not match package.name_123.apk")
1668 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1669 help="Include APKs that are signed with disabled algorithms like MD5")
1670 metadata.add_metadata_arguments(parser)
1671 options = parser.parse_args()
1672 metadata.warnings_action = options.W
1674 config = common.read_config(options)
1676 if not ('jarsigner' in config and 'keytool' in config):
1677 raise FDroidException('Java JDK not found! Install in standard location or set java_paths!')
1680 if config['archive_older'] != 0:
1681 repodirs.append('archive')
1682 if not os.path.exists('archive'):
1686 resize_all_icons(repodirs)
1689 if options.rename_apks:
1690 options.clean = True
1692 # check that icons exist now, rather than fail at the end of `fdroid update`
1693 for k in ['repo_icon', 'archive_icon']:
1695 if not os.path.exists(config[k]):
1696 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1699 # if the user asks to create a keystore, do it now, reusing whatever it can
1700 if options.create_key:
1701 if os.path.exists(config['keystore']):
1702 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1703 logging.critical("\t'" + config['keystore'] + "'")
1706 if 'repo_keyalias' not in config:
1707 config['repo_keyalias'] = socket.getfqdn()
1708 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1709 if 'keydname' not in config:
1710 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1711 common.write_to_config(config, 'keydname', config['keydname'])
1712 if 'keystore' not in config:
1713 config['keystore'] = common.default_config['keystore']
1714 common.write_to_config(config, 'keystore', config['keystore'])
1716 password = common.genpassword()
1717 if 'keystorepass' not in config:
1718 config['keystorepass'] = password
1719 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1720 if 'keypass' not in config:
1721 config['keypass'] = password
1722 common.write_to_config(config, 'keypass', config['keypass'])
1723 common.genkeystore(config)
1726 apps = metadata.read_metadata()
1728 # Generate a list of categories...
1730 for app in apps.values():
1731 categories.update(app.Categories)
1733 # Read known apks data (will be updated and written back when we've finished)
1734 knownapks = common.KnownApks()
1737 apkcache = get_cache()
1739 # Delete builds for disabled apps
1740 delete_disabled_builds(apps, apkcache, repodirs)
1742 # Scan all apks in the main repo
1743 apks, cachechanged = process_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1745 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1746 options.use_date_from_apk)
1747 cachechanged = cachechanged or fcachechanged
1749 # Generate warnings for apk's with no metadata (or create skeleton
1750 # metadata files, if requested on the command line)
1753 if apk['packageName'] not in apps:
1754 if options.create_metadata:
1755 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1756 app = metadata.App()
1758 app.Name = apk['name']
1759 app.Summary = apk['name']
1761 logging.warn(apk['packageName'] + ' does not have a name! Using package name instead.')
1762 app.Name = apk['packageName']
1763 app.Summary = apk['packageName']
1764 app.CurrentVersionCode = 2147483647 # Java's Integer.MAX_VALUE
1765 app.Categories = [os.path.basename(os.path.dirname(os.getcwd()))]
1766 metadata.write_yaml(f, app)
1767 logging.info("Generated skeleton metadata for " + apk['packageName'])
1770 msg = apk['apkName'] + " (" + apk['packageName'] + ") has no metadata!"
1771 if options.delete_unknown:
1772 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkName'])
1773 rmf = os.path.join(repodirs[0], apk['apkName'])
1774 if not os.path.exists(rmf):
1775 logging.error("Could not find {0} to remove it".format(rmf))
1779 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1781 # update the metadata with the newly created ones included
1783 apps = metadata.read_metadata()
1785 copy_triple_t_store_metadata(apps)
1786 insert_obbs(repodirs[0], apps, apks)
1787 insert_localized_app_metadata(apps)
1788 translate_per_build_anti_features(apps, apks)
1790 # Scan the archive repo for apks as well
1791 if len(repodirs) > 1:
1792 archapks, cc = process_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1798 # Apply information from latest apks to the application and update dates
1799 apply_info_from_latest_apk(apps, apks + archapks)
1801 # Sort the app list by name, then the web site doesn't have to by default.
1802 # (we had to wait until we'd scanned the apks to do this, because mostly the
1803 # name comes from there!)
1804 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1806 # APKs are placed into multiple repos based on the app package, providing
1807 # per-app subscription feeds for nightly builds and things like it
1808 if config['per_app_repos']:
1809 add_apks_to_per_app_repos(repodirs[0], apks)
1810 for appid, app in apps.items():
1811 repodir = os.path.join(appid, 'fdroid', 'repo')
1813 appdict[appid] = app
1814 if os.path.isdir(repodir):
1815 index.make(appdict, [appid], apks, repodir, False)
1817 logging.info('Skipping index generation for ' + appid)
1820 if len(repodirs) > 1:
1821 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1823 # Make the index for the main repo...
1824 index.make(apps, sortedids, apks, repodirs[0], False)
1825 make_categories_txt(repodirs[0], categories)
1827 # If there's an archive repo, make the index for it. We already scanned it
1829 if len(repodirs) > 1:
1830 index.make(apps, sortedids, archapks, repodirs[1], True)
1832 git_remote = config.get('binary_transparency_remote')
1833 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
1835 btlog.make_binary_transparency_log(repodirs)
1837 if config['update_stats']:
1838 # Update known apks info...
1839 knownapks.writeifchanged()
1841 # Generate latest apps data for widget
1842 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1844 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1846 appid = line.rstrip()
1847 data += appid + "\t"
1849 data += app.Name + "\t"
1850 if app.icon is not None:
1851 data += app.icon + "\t"
1852 data += app.License + "\n"
1853 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1857 write_cache(apkcache)
1859 # Update the wiki...
1861 update_wiki(apps, sortedids, apks + archapks)
1863 logging.info("Finished.")
1866 if __name__ == "__main__":