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
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_PAT = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
57 APK_ICON_PAT = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
58 APK_ICON_PAT_NODPI = re.compile(".*icon='([^']+?)'.*")
59 APK_SDK_VERSION_PAT = re.compile(".*'([0-9]*)'.*")
60 APK_PERMISSION_PAT = \
61 re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
62 APK_FEATURE_PAT = re.compile(".*name='([^']*)'.*")
64 screen_densities = ['640', '480', '320', '240', '160', '120']
65 screen_resolutions = {
77 all_screen_densities = ['0'] + screen_densities
79 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
80 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
82 ALLOWED_EXTENSIONS = ('png', 'jpg', 'jpeg')
83 GRAPHIC_NAMES = ('featureGraphic', 'icon', 'promoGraphic', 'tvBanner')
84 SCREENSHOT_DIRS = ('phoneScreenshots', 'sevenInchScreenshots',
85 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots')
88 def dpi_to_px(density):
89 return (int(density) * 48) / 160
93 return (int(px) * 160) / 48
96 def get_icon_dir(repodir, density):
98 return os.path.join(repodir, "icons")
99 return os.path.join(repodir, "icons-%s" % density)
102 def get_icon_dirs(repodir):
103 for density in screen_densities:
104 yield get_icon_dir(repodir, density)
107 def get_all_icon_dirs(repodir):
108 for density in all_screen_densities:
109 yield get_icon_dir(repodir, density)
112 def update_wiki(apps, sortedids, apks):
115 :param apps: fully populated list of all applications
116 :param apks: all apks, except...
118 logging.info("Updating wiki")
120 wikiredircat = 'App Redirects'
122 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
123 path=config['wiki_path'])
124 site.login(config['wiki_user'], config['wiki_password'])
126 generated_redirects = {}
128 for appid in sortedids:
129 app = metadata.App(apps[appid])
133 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
135 for af in sorted(app.AntiFeatures):
136 wikidata += '{{AntiFeature|' + af + '}}\n'
141 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' % (
144 app.added.strftime('%Y-%m-%d') if app.added else '',
145 app.lastUpdated.strftime('%Y-%m-%d') if app.lastUpdated else '',
161 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
163 wikidata += app.Summary
164 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
166 wikidata += "=Description=\n"
167 wikidata += metadata.description_wiki(app.Description) + "\n"
169 wikidata += "=Maintainer Notes=\n"
170 if app.MaintainerNotes:
171 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
172 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)
174 # Get a list of all packages for this application...
176 gotcurrentver = False
180 if apk['packageName'] == appid:
181 if str(apk['versionCode']) == app.CurrentVersionCode:
184 # Include ones we can't build, as a special case...
185 for build in app.builds:
187 if build.versionCode == app.CurrentVersionCode:
189 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
190 apklist.append({'versionCode': int(build.versionCode),
191 'versionName': build.versionName,
192 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
197 if apk['versionCode'] == int(build.versionCode):
202 apklist.append({'versionCode': int(build.versionCode),
203 'versionName': build.versionName,
204 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
206 if app.CurrentVersionCode == '0':
208 # Sort with most recent first...
209 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
211 wikidata += "=Versions=\n"
212 if len(apklist) == 0:
213 wikidata += "We currently have no versions of this app available."
214 elif not gotcurrentver:
215 wikidata += "We don't have the current version of this app."
217 wikidata += "We have the current version of this app."
218 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
219 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
220 if len(app.NoSourceSince) > 0:
221 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
222 if len(app.CurrentVersion) > 0:
223 wikidata += "The current (recommended) version is " + app.CurrentVersion
224 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
227 wikidata += "==" + apk['versionName'] + "==\n"
229 if 'buildproblem' in apk:
230 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
233 wikidata += "This version is built and signed by "
235 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
237 wikidata += "the original developer.\n\n"
238 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
240 wikidata += '\n[[Category:' + wikicat + ']]\n'
241 if len(app.NoSourceSince) > 0:
242 wikidata += '\n[[Category:Apps missing source code]]\n'
243 if validapks == 0 and not app.Disabled:
244 wikidata += '\n[[Category:Apps with no packages]]\n'
245 if cantupdate and not app.Disabled:
246 wikidata += "\n[[Category:Apps we cannot update]]\n"
247 if buildfails and not app.Disabled:
248 wikidata += "\n[[Category:Apps with failing builds]]\n"
249 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
250 wikidata += '\n[[Category:Apps to Update]]\n'
252 wikidata += '\n[[Category:Apps that are disabled]]\n'
253 if app.UpdateCheckMode == 'None' and not app.Disabled:
254 wikidata += '\n[[Category:Apps with no update check]]\n'
255 for appcat in app.Categories:
256 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
258 # We can't have underscores in the page name, even if they're in
259 # the package ID, because MediaWiki messes with them...
260 pagename = appid.replace('_', ' ')
262 # Drop a trailing newline, because mediawiki is going to drop it anyway
263 # and it we don't we'll think the page has changed when it hasn't...
264 if wikidata.endswith('\n'):
265 wikidata = wikidata[:-1]
267 generated_pages[pagename] = wikidata
269 # Make a redirect from the name to the ID too, unless there's
270 # already an existing page with the name and it isn't a redirect.
272 apppagename = app.Name.replace('_', ' ')
273 apppagename = apppagename.replace('{', '')
274 apppagename = apppagename.replace('}', ' ')
275 apppagename = apppagename.replace(':', ' ')
276 apppagename = apppagename.replace('[', ' ')
277 apppagename = apppagename.replace(']', ' ')
278 # Drop double spaces caused mostly by replacing ':' above
279 apppagename = apppagename.replace(' ', ' ')
280 for expagename in site.allpages(prefix=apppagename,
281 filterredir='nonredirects',
283 if expagename == apppagename:
285 # Another reason not to make the redirect page is if the app name
286 # is the same as it's ID, because that will overwrite the real page
287 # with an redirect to itself! (Although it seems like an odd
288 # scenario this happens a lot, e.g. where there is metadata but no
289 # builds or binaries to extract a name from.
290 if apppagename == pagename:
293 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
295 for tcat, genp in [(wikicat, generated_pages),
296 (wikiredircat, generated_redirects)]:
297 catpages = site.Pages['Category:' + tcat]
299 for page in catpages:
300 existingpages.append(page.name)
301 if page.name in genp:
302 pagetxt = page.edit()
303 if pagetxt != genp[page.name]:
304 logging.debug("Updating modified page " + page.name)
305 page.save(genp[page.name], summary='Auto-updated')
307 logging.debug("Page " + page.name + " is unchanged")
309 logging.warn("Deleting page " + page.name)
310 page.delete('No longer published')
311 for pagename, text in genp.items():
312 logging.debug("Checking " + pagename)
313 if pagename not in existingpages:
314 logging.debug("Creating page " + pagename)
316 newpage = site.Pages[pagename]
317 newpage.save(text, summary='Auto-created')
318 except Exception as e:
319 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
321 # Purge server cache to ensure counts are up to date
322 site.pages['Repository Maintenance'].purge()
325 def delete_disabled_builds(apps, apkcache, repodirs):
326 """Delete disabled build outputs.
328 :param apps: list of all applications, as per metadata.read_metadata
329 :param apkcache: current apk cache information
330 :param repodirs: the repo directories to process
332 for appid, app in apps.items():
333 for build in app['builds']:
334 if not build.disable:
336 apkfilename = common.get_release_filename(app, build)
337 iconfilename = "%s.%s.png" % (
340 for repodir in repodirs:
342 os.path.join(repodir, apkfilename),
343 os.path.join(repodir, apkfilename + '.asc'),
344 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
346 for density in all_screen_densities:
347 repo_dir = get_icon_dir(repodir, density)
348 files.append(os.path.join(repo_dir, iconfilename))
351 if os.path.exists(f):
352 logging.info("Deleting disabled build output " + f)
354 if apkfilename in apkcache:
355 del apkcache[apkfilename]
358 def resize_icon(iconpath, density):
360 if not os.path.isfile(iconpath):
365 fp = open(iconpath, 'rb')
367 size = dpi_to_px(density)
369 if any(length > size for length in im.size):
371 im.thumbnail((size, size), Image.ANTIALIAS)
372 logging.debug("%s was too large at %s - new size is %s" % (
373 iconpath, oldsize, im.size))
374 im.save(iconpath, "PNG")
376 except Exception as e:
377 logging.error(_("Failed resizing {path}: {error}".format(path=iconpath, error=e)))
384 def resize_all_icons(repodirs):
385 """Resize all icons that exceed the max size
387 :param repodirs: the repo directories to process
389 for repodir in repodirs:
390 for density in screen_densities:
391 icon_dir = get_icon_dir(repodir, density)
392 icon_glob = os.path.join(icon_dir, '*.png')
393 for iconpath in glob.glob(icon_glob):
394 resize_icon(iconpath, density)
398 """ Get the signing certificate of an apk. To get the same md5 has that
399 Android gets, we encode the .RSA certificate in a specific format and pass
400 it hex-encoded to the md5 digest algorithm.
402 :param apkpath: path to the apk
403 :returns: A string containing the md5 of the signature of the apk or None
404 if an error occurred.
407 with zipfile.ZipFile(apkpath, 'r') as apk:
408 certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)]
411 logging.error(_("No signing certificates found in {path}").format(path=apkpath))
414 logging.error(_("Found multiple signing certificates in {path}").format(path=apkpath))
417 cert = apk.read(certs[0])
419 cert_encoded = common.get_certificate(cert)
421 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
424 def get_cache_file():
425 return os.path.join('tmp', 'apkcache')
429 """Get the cached dict of the APK index
431 Gather information about all the apk files in the repo directory,
432 using cached data if possible. Some of the index operations take a
433 long time, like calculating the SHA-256 and verifying the APK
436 The cache is invalidated if the metadata version is different, or
437 the 'allow_disabled_algorithms' config/option is different. In
438 those cases, there is no easy way to know what has changed from
439 the cache, so just rerun the whole thing.
444 apkcachefile = get_cache_file()
445 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
446 if not options.clean and os.path.exists(apkcachefile):
447 with open(apkcachefile, 'rb') as cf:
448 apkcache = pickle.load(cf, encoding='utf-8')
449 if apkcache.get("METADATA_VERSION") != METADATA_VERSION \
450 or apkcache.get('allow_disabled_algorithms') != ada:
455 apkcache["METADATA_VERSION"] = METADATA_VERSION
456 apkcache['allow_disabled_algorithms'] = ada
461 def write_cache(apkcache):
462 apkcachefile = get_cache_file()
463 cache_path = os.path.dirname(apkcachefile)
464 if not os.path.exists(cache_path):
465 os.makedirs(cache_path)
466 with open(apkcachefile, 'wb') as cf:
467 pickle.dump(apkcache, cf)
470 def get_icon_bytes(apkzip, iconsrc):
471 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
473 return apkzip.read(iconsrc)
475 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
478 def sha256sum(filename):
479 '''Calculate the sha256 of the given file'''
480 sha = hashlib.sha256()
481 with open(filename, 'rb') as f:
487 return sha.hexdigest()
490 def has_known_vulnerability(filename):
491 """checks for known vulnerabilities in the APK
493 Checks OpenSSL .so files in the APK to see if they are a known vulnerable
494 version. Google also enforces this:
495 https://support.google.com/faqs/answer/6376725?hl=en
497 Checks whether there are more than one classes.dex or AndroidManifest.xml
498 files, which is invalid and an essential part of the "Master Key" attack.
499 http://www.saurik.com/id/17
501 Janus is similar to Master Key but is perhaps easier to scan for.
502 https://www.guardsquare.com/en/blog/new-android-vulnerability-allows-attackers-modify-apps-without-affecting-their-signatures
507 # statically load this pattern
508 if not hasattr(has_known_vulnerability, "pattern"):
509 has_known_vulnerability.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
511 with open(filename.encode(), 'rb') as fp:
513 if first4 != b'\x50\x4b\x03\x04':
514 raise FDroidException(_('{path} has bad file signature "{pattern}", possible Janus exploit!')
515 .format(path=filename, pattern=first4.decode().replace('\n', ' ')) + '\n'
516 + 'https://www.guardsquare.com/en/blog/new-android-vulnerability-allows-attackers-modify-apps-without-affecting-their-signatures')
519 with zipfile.ZipFile(filename) as zf:
520 for name in zf.namelist():
521 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
524 chunk = lib.read(4096)
527 m = has_known_vulnerability.pattern.search(chunk)
529 version = m.group(1).decode('ascii')
530 if (version.startswith('1.0.1') and len(version) > 5 and version[5] >= 'r') \
531 or (version.startswith('1.0.2') and len(version) > 5 and version[5] >= 'f') \
532 or re.match(r'[1-9]\.[1-9]\.[0-9].*', version):
533 logging.debug(_('"{path}" contains recent {name} ({version})')
534 .format(path=filename, name=name, version=version))
536 logging.warning(_('"{path}" contains outdated {name} ({version})')
537 .format(path=filename, name=name, version=version))
540 elif name == 'AndroidManifest.xml' or name == 'classes.dex' or name.endswith('.so'):
541 if name in files_in_apk:
542 logging.warning(_('{apkfilename} has multiple {name} files, looks like Master Key exploit!')
543 .format(apkfilename=filename, name=name))
545 files_in_apk.add(name)
549 def insert_obbs(repodir, apps, apks):
550 """Scans the .obb files in a given repo directory and adds them to the
551 relevant APK instances. OBB files have versionCodes like APK
552 files, and they are loosely associated. If there is an OBB file
553 present, then any APK with the same or higher versionCode will use
554 that OBB file. There are two OBB types: main and patch, each APK
555 can only have only have one of each.
557 https://developer.android.com/google/play/expansion-files.html
559 :param repodir: repo directory to scan
560 :param apps: list of current, valid apps
561 :param apks: current information on all APKs
565 def obbWarnDelete(f, msg):
566 logging.warning(msg + ' ' + f)
567 if options.delete_unknown:
568 logging.error(_("Deleting unknown file: {path}").format(path=f))
572 java_Integer_MIN_VALUE = -pow(2, 31)
573 currentPackageNames = apps.keys()
574 for f in glob.glob(os.path.join(repodir, '*.obb')):
575 obbfile = os.path.basename(f)
576 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
577 chunks = obbfile.split('.')
578 if chunks[0] != 'main' and chunks[0] != 'patch':
579 obbWarnDelete(f, _('OBB filename must start with "main." or "patch.":'))
581 if not re.match(r'^-?[0-9]+$', chunks[1]):
582 obbWarnDelete(f, _('The OBB version code must come after "{name}.":')
583 .format(name=chunks[0]))
585 versionCode = int(chunks[1])
586 packagename = ".".join(chunks[2:-1])
588 highestVersionCode = java_Integer_MIN_VALUE
589 if packagename not in currentPackageNames:
590 obbWarnDelete(f, _("OBB's packagename does not match a supported APK:"))
593 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
594 highestVersionCode = apk['versionCode']
595 if versionCode > highestVersionCode:
596 obbWarnDelete(f, _('OBB file has newer versionCode({integer}) than any APK:')
597 .format(integer=str(versionCode)))
599 obbsha256 = sha256sum(f)
600 obbs.append((packagename, versionCode, obbfile, obbsha256))
603 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
604 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
605 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
606 apk['obbMainFile'] = obbfile
607 apk['obbMainFileSha256'] = obbsha256
608 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
609 apk['obbPatchFile'] = obbfile
610 apk['obbPatchFileSha256'] = obbsha256
611 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
615 def translate_per_build_anti_features(apps, apks):
616 """Grab the anti-features list from the build metadata
618 For most Anti-Features, they are really most applicable per-APK,
619 not for an app. An app can fix a vulnerability, add/remove
620 tracking, etc. This reads the 'antifeatures' list from the Build
621 entries in the fdroiddata metadata file, then transforms it into
622 the 'antiFeatures' list of unique items for the index.
624 The field key is all lower case in the metadata file to match the
625 rest of the Build fields. It is 'antiFeatures' camel case in the
626 implementation, index, and fdroidclient since it is translated
627 from the build 'antifeatures' field, not directly included.
631 antiFeatures = dict()
632 for packageName, app in apps.items():
634 for build in app['builds']:
635 afl = build.get('antifeatures')
637 d[int(build.versionCode)] = afl
639 antiFeatures[packageName] = d
642 d = antiFeatures.get(apk['packageName'])
644 afl = d.get(apk['versionCode'])
646 apk['antiFeatures'].update(afl)
649 def _get_localized_dict(app, locale):
650 '''get the dict to add localized store metadata to'''
651 if 'localized' not in app:
652 app['localized'] = collections.OrderedDict()
653 if locale not in app['localized']:
654 app['localized'][locale] = collections.OrderedDict()
655 return app['localized'][locale]
658 def _set_localized_text_entry(app, locale, key, f):
659 limit = config['char_limits'][key]
660 localized = _get_localized_dict(app, locale)
662 text = fp.read()[:limit]
664 localized[key] = text
667 def _set_author_entry(app, key, f):
668 limit = config['char_limits']['author']
670 text = fp.read()[:limit]
675 def copy_triple_t_store_metadata(apps):
676 """Include store metadata from the app's source repo
678 The Triple-T Gradle Play Publisher is a plugin that has a standard
679 file layout for all of the metadata and graphics that the Google
680 Play Store accepts. Since F-Droid has the git repo, it can just
681 pluck those files directly. This method reads any text files into
682 the app dict, then copies any graphics into the fdroid repo
685 This needs to be run before insert_localized_app_metadata() so that
686 the graphics files that are copied into the fdroid repo get
689 https://github.com/Triple-T/gradle-play-publisher#upload-images
690 https://github.com/Triple-T/gradle-play-publisher#play-store-metadata
694 if not os.path.isdir('build'):
695 return # nothing to do
697 for packageName, app in apps.items():
698 for d in glob.glob(os.path.join('build', packageName, '*', 'src', '*', 'play')):
699 logging.debug('Triple-T Gradle Play Publisher: ' + d)
700 for root, dirs, files in os.walk(d):
701 segments = root.split('/')
702 locale = segments[-2]
704 if f == 'fulldescription':
705 _set_localized_text_entry(app, locale, 'description',
706 os.path.join(root, f))
708 elif f == 'shortdescription':
709 _set_localized_text_entry(app, locale, 'summary',
710 os.path.join(root, f))
713 _set_localized_text_entry(app, locale, 'name',
714 os.path.join(root, f))
717 _set_localized_text_entry(app, locale, 'video',
718 os.path.join(root, f))
720 elif f == 'whatsnew':
721 _set_localized_text_entry(app, segments[-1], 'whatsNew',
722 os.path.join(root, f))
724 elif f == 'contactEmail':
725 _set_author_entry(app, 'authorEmail', os.path.join(root, f))
727 elif f == 'contactPhone':
728 _set_author_entry(app, 'authorPhone', os.path.join(root, f))
730 elif f == 'contactWebsite':
731 _set_author_entry(app, 'authorWebSite', os.path.join(root, f))
734 base, extension = common.get_extension(f)
735 dirname = os.path.basename(root)
736 if extension in ALLOWED_EXTENSIONS \
737 and (dirname in GRAPHIC_NAMES or dirname in SCREENSHOT_DIRS):
738 if segments[-2] == 'listing':
739 locale = segments[-3]
741 locale = segments[-2]
742 destdir = os.path.join('repo', packageName, locale, dirname)
743 os.makedirs(destdir, mode=0o755, exist_ok=True)
744 sourcefile = os.path.join(root, f)
745 destfile = os.path.join(destdir, os.path.basename(f))
746 logging.debug('copying ' + sourcefile + ' ' + destfile)
747 shutil.copy(sourcefile, destfile)
750 def insert_localized_app_metadata(apps):
751 """scans standard locations for graphics and localized text
753 Scans for localized description files, store graphics, and
754 screenshot PNG files in statically defined screenshots directory
755 and adds them to the app metadata. The screenshots and graphic
756 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
757 and must be in the following layout:
758 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
760 repo/packageName/locale/featureGraphic.png
761 repo/packageName/locale/phoneScreenshots/1.png
762 repo/packageName/locale/phoneScreenshots/2.png
764 The changelog files must be text files named with the versionCode
765 ending with ".txt" and must be in the following layout:
766 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
768 repo/packageName/locale/changelogs/12345.txt
770 This will scan the each app's source repo then the metadata/ dir
771 for these standard locations of changelog files. If it finds
772 them, they will be added to the dict of all packages, with the
773 versions in the metadata/ folder taking precendence over the what
774 is in the app's source repo.
776 Where "packageName" is the app's packageName and "locale" is the locale
777 of the graphics, e.g. what language they are in, using the IETF RFC5646
778 format (en-US, fr-CA, es-MX, etc).
780 This will also scan the app's git for a fastlane folder, and the
781 metadata/ folder and the apps' source repos for standard locations
782 of graphic and screenshot files. If it finds them, it will copy
783 them into the repo. The fastlane files follow this pattern:
784 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
788 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'src', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
789 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
790 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
791 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
793 for srcd in sorted(sourcedirs):
794 if not os.path.isdir(srcd):
796 for root, dirs, files in os.walk(srcd):
797 segments = root.split('/')
798 packageName = segments[1]
799 if packageName not in apps:
800 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
802 locale = segments[-1]
803 destdir = os.path.join('repo', packageName, locale)
805 # flavours specified in build receipt
807 if apps[packageName] and 'builds' in apps[packageName] and len(apps[packageName].builds) > 0\
808 and 'gradle' in apps[packageName].builds[-1]:
809 build_flavours = apps[packageName].builds[-1].gradle
811 if len(segments) >= 5 and segments[4] == "fastlane" and segments[3] not in build_flavours:
812 logging.debug("ignoring due to wrong flavour")
816 if f in ('description.txt', 'full_description.txt'):
817 _set_localized_text_entry(apps[packageName], locale, 'description',
818 os.path.join(root, f))
820 elif f in ('summary.txt', 'short_description.txt'):
821 _set_localized_text_entry(apps[packageName], locale, 'summary',
822 os.path.join(root, f))
824 elif f in ('name.txt', 'title.txt'):
825 _set_localized_text_entry(apps[packageName], locale, 'name',
826 os.path.join(root, f))
828 elif f == 'video.txt':
829 _set_localized_text_entry(apps[packageName], locale, 'video',
830 os.path.join(root, f))
832 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
833 locale = segments[-2]
834 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
835 os.path.join(root, f))
838 base, extension = common.get_extension(f)
839 if locale == 'images':
840 locale = segments[-2]
841 destdir = os.path.join('repo', packageName, locale)
842 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
843 os.makedirs(destdir, mode=0o755, exist_ok=True)
844 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
845 shutil.copy(os.path.join(root, f), destdir)
847 if d in SCREENSHOT_DIRS:
848 if locale == 'images':
849 locale = segments[-2]
850 destdir = os.path.join('repo', packageName, locale)
851 for f in glob.glob(os.path.join(root, d, '*.*')):
852 _ignored, extension = common.get_extension(f)
853 if extension in ALLOWED_EXTENSIONS:
854 screenshotdestdir = os.path.join(destdir, d)
855 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
856 logging.debug('copying ' + f + ' ' + screenshotdestdir)
857 shutil.copy(f, screenshotdestdir)
859 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
861 if not os.path.isdir(d):
863 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
864 if not os.path.isfile(f):
866 segments = f.split('/')
867 packageName = segments[1]
869 screenshotdir = segments[3]
870 filename = os.path.basename(f)
871 base, extension = common.get_extension(filename)
873 if packageName not in apps:
874 logging.warning(_('Found "{path}" graphic without metadata for app "{name}"!')
875 .format(path=filename, name=packageName))
877 graphics = _get_localized_dict(apps[packageName], locale)
879 if extension not in ALLOWED_EXTENSIONS:
880 logging.warning(_('Only PNG and JPEG are supported for graphics, found: {path}').format(path=f))
881 elif base in GRAPHIC_NAMES:
882 # there can only be zero or one of these per locale
883 graphics[base] = filename
884 elif screenshotdir in SCREENSHOT_DIRS:
885 # there can any number of these per locale
886 logging.debug(_('adding to {name}: {path}').format(name=screenshotdir, path=f))
887 if screenshotdir not in graphics:
888 graphics[screenshotdir] = []
889 graphics[screenshotdir].append(filename)
891 logging.warning(_('Unsupported graphics file found: {path}').format(path=f))
894 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
895 """Scan a repo for all files with an extension except APK/OBB
897 :param apkcache: current cached info about all repo files
898 :param repodir: repo directory to scan
899 :param knownapks: list of all known files, as per metadata.read_metadata
900 :param use_date_from_file: use date from file (instead of current date)
901 for newly added files
906 repodir = repodir.encode('utf-8')
907 for name in os.listdir(repodir):
908 file_extension = common.get_file_extension(name)
909 if file_extension == 'apk' or file_extension == 'obb':
911 filename = os.path.join(repodir, name)
912 name_utf8 = name.decode('utf-8')
913 if filename.endswith(b'_src.tar.gz'):
914 logging.debug(_('skipping source tarball: {path}')
915 .format(path=filename.decode('utf-8')))
917 if not common.is_repo_file(filename):
919 stat = os.stat(filename)
920 if stat.st_size == 0:
921 raise FDroidException(_('{path} is zero size!')
922 .format(path=filename))
924 shasum = sha256sum(filename)
927 repo_file = apkcache[name]
928 # added time is cached as tuple but used here as datetime instance
929 if 'added' in repo_file:
930 a = repo_file['added']
931 if isinstance(a, datetime):
932 repo_file['added'] = a
934 repo_file['added'] = datetime(*a[:6])
935 if repo_file.get('hash') == shasum:
936 logging.debug(_("Reading {apkfilename} from cache")
937 .format(apkfilename=name_utf8))
940 logging.debug(_("Ignoring stale cache data for {apkfilename}")
941 .format(apkfilename=name_utf8))
944 logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8))
945 repo_file = collections.OrderedDict()
946 repo_file['name'] = os.path.splitext(name_utf8)[0]
947 # TODO rename apkname globally to something more generic
948 repo_file['apkName'] = name_utf8
949 repo_file['hash'] = shasum
950 repo_file['hashType'] = 'sha256'
951 repo_file['versionCode'] = 0
952 repo_file['versionName'] = shasum
953 # the static ID is the SHA256 unless it is set in the metadata
954 repo_file['packageName'] = shasum
956 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
958 repo_file['packageName'] = m.group(1)
959 repo_file['versionCode'] = int(m.group(2))
960 srcfilename = name + b'_src.tar.gz'
961 if os.path.exists(os.path.join(repodir, srcfilename)):
962 repo_file['srcname'] = srcfilename.decode('utf-8')
963 repo_file['size'] = stat.st_size
965 apkcache[name] = repo_file
968 if use_date_from_file:
969 timestamp = stat.st_ctime
970 default_date_param = time.gmtime(time.mktime(datetime.fromtimestamp(timestamp).timetuple()))
972 default_date_param = None
974 # Record in knownapks, getting the added date at the same time..
975 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
976 default_date=default_date_param)
978 repo_file['added'] = added
980 repo_files.append(repo_file)
982 return repo_files, cachechanged
985 def scan_apk(apk_file):
987 Scans an APK file and returns dictionary with metadata of the APK.
989 Attention: This does *not* verify that the APK signature is correct.
991 :param apk_file: The (ideally absolute) path to the APK file
992 :raises BuildException
993 :return A dict containing APK metadata
996 'hash': sha256sum(apk_file),
997 'hashType': 'sha256',
998 'uses-permission': [],
999 'uses-permission-sdk-23': [],
1003 'antiFeatures': set(),
1006 if SdkToolsPopen(['aapt', 'version'], output=False):
1007 scan_apk_aapt(apk, apk_file)
1009 scan_apk_androguard(apk, apk_file)
1011 # Get the signature, or rather the signing key fingerprints
1012 logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
1013 apk['sig'] = getsig(apk_file)
1015 raise BuildException("Failed to get apk signature")
1016 apk['signer'] = common.apk_signer_fingerprint(os.path.join(os.getcwd(),
1018 if not apk.get('signer'):
1019 raise BuildException("Failed to get apk signing key fingerprint")
1021 # Get size of the APK
1022 apk['size'] = os.path.getsize(apk_file)
1024 if 'minSdkVersion' not in apk:
1025 logging.warning("No SDK version information found in {0}".format(apk_file))
1026 apk['minSdkVersion'] = 1
1028 # Check for known vulnerabilities
1029 if has_known_vulnerability(apk_file):
1030 apk['antiFeatures'].add('KnownVuln')
1035 def scan_apk_aapt(apk, apkfile):
1036 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1037 if p.returncode != 0:
1038 if options.delete_unknown:
1039 if os.path.exists(apkfile):
1040 logging.error(_("Failed to get apk information, deleting {path}").format(path=apkfile))
1043 logging.error("Could not find {0} to remove it".format(apkfile))
1045 logging.error(_("Failed to get apk information, skipping {path}").format(path=apkfile))
1046 raise BuildException(_("Invalid APK"))
1047 for line in p.output.splitlines():
1048 if line.startswith("package:"):
1050 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
1051 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
1052 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
1053 except Exception as e:
1054 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
1055 elif line.startswith("application:"):
1056 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1057 # Keep path to non-dpi icon in case we need it
1058 match = re.match(APK_ICON_PAT_NODPI, line)
1060 apk['icons_src']['-1'] = match.group(1)
1061 elif line.startswith("launchable-activity:"):
1062 # Only use launchable-activity as fallback to application
1064 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1065 if '-1' not in apk['icons_src']:
1066 match = re.match(APK_ICON_PAT_NODPI, line)
1068 apk['icons_src']['-1'] = match.group(1)
1069 elif line.startswith("application-icon-"):
1070 match = re.match(APK_ICON_PAT, line)
1072 density = match.group(1)
1073 path = match.group(2)
1074 apk['icons_src'][density] = path
1075 elif line.startswith("sdkVersion:"):
1076 m = re.match(APK_SDK_VERSION_PAT, line)
1078 logging.error(line.replace('sdkVersion:', '')
1079 + ' is not a valid minSdkVersion!')
1081 apk['minSdkVersion'] = m.group(1)
1082 # if target not set, default to min
1083 if 'targetSdkVersion' not in apk:
1084 apk['targetSdkVersion'] = m.group(1)
1085 elif line.startswith("targetSdkVersion:"):
1086 m = re.match(APK_SDK_VERSION_PAT, line)
1088 logging.error(line.replace('targetSdkVersion:', '')
1089 + ' is not a valid targetSdkVersion!')
1091 apk['targetSdkVersion'] = m.group(1)
1092 elif line.startswith("maxSdkVersion:"):
1093 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
1094 elif line.startswith("native-code:"):
1095 apk['nativecode'] = []
1096 for arch in line[13:].split(' '):
1097 apk['nativecode'].append(arch[1:-1])
1098 elif line.startswith('uses-permission:'):
1099 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1100 if perm_match['maxSdkVersion']:
1101 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1102 permission = UsesPermission(
1104 perm_match['maxSdkVersion']
1107 apk['uses-permission'].append(permission)
1108 elif line.startswith('uses-permission-sdk-23:'):
1109 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1110 if perm_match['maxSdkVersion']:
1111 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1112 permission_sdk_23 = UsesPermissionSdk23(
1114 perm_match['maxSdkVersion']
1117 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1119 elif line.startswith('uses-feature:'):
1120 feature = re.match(APK_FEATURE_PAT, line).group(1)
1121 # Filter out this, it's only added with the latest SDK tools and
1122 # causes problems for lots of apps.
1123 if feature != "android.hardware.screen.portrait" \
1124 and feature != "android.hardware.screen.landscape":
1125 if feature.startswith("android.feature."):
1126 feature = feature[16:]
1127 apk['features'].add(feature)
1130 def scan_apk_androguard(apk, apkfile):
1132 from androguard.core.bytecodes.apk import APK
1133 apkobject = APK(apkfile)
1134 if apkobject.is_valid_APK():
1135 arsc = apkobject.get_android_resources()
1137 if options.delete_unknown:
1138 if os.path.exists(apkfile):
1139 logging.error(_("Failed to get apk information, deleting {path}")
1140 .format(path=apkfile))
1143 logging.error(_("Could not find {path} to remove it")
1144 .format(path=apkfile))
1146 logging.error(_("Failed to get apk information, skipping {path}")
1147 .format(path=apkfile))
1148 raise BuildException(_("Invalid APK"))
1150 raise FDroidException("androguard library is not installed and aapt not present")
1151 except FileNotFoundError:
1152 logging.error(_("Could not open apk file for analysis"))
1153 raise BuildException(_("Invalid APK"))
1155 apk['packageName'] = apkobject.get_package()
1156 apk['versionCode'] = int(apkobject.get_androidversion_code())
1157 apk['versionName'] = apkobject.get_androidversion_name()
1158 if apk['versionName'][0] == "@":
1159 version_id = int(apk['versionName'].replace("@", "0x"), 16)
1160 version_id = arsc.get_id(apk['packageName'], version_id)[1]
1161 apk['versionName'] = arsc.get_string(apk['packageName'], version_id)[1]
1162 apk['name'] = apkobject.get_app_name()
1164 if apkobject.get_max_sdk_version() is not None:
1165 apk['maxSdkVersion'] = apkobject.get_max_sdk_version()
1166 apk['minSdkVersion'] = apkobject.get_min_sdk_version()
1167 apk['targetSdkVersion'] = apkobject.get_target_sdk_version()
1169 icon_id = int(apkobject.get_element("application", "icon").replace("@", "0x"), 16)
1170 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1172 density_re = re.compile("^res/(.*)/" + icon_name + ".*$")
1174 for file in apkobject.get_files():
1175 d_re = density_re.match(file)
1177 folder = d_re.group(1).split('-')
1179 resolution = folder[1]
1182 density = screen_resolutions[resolution]
1183 apk['icons_src'][density] = d_re.group(0)
1185 if apk['icons_src'].get('-1') is None:
1186 apk['icons_src']['-1'] = apk['icons_src']['160']
1188 arch_re = re.compile("^lib/(.*)/.*$")
1189 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1191 apk['nativecode'] = []
1192 apk['nativecode'].extend(sorted(list(arch)))
1194 xml = apkobject.get_android_manifest_xml()
1196 for item in xml.getElementsByTagName('uses-permission'):
1197 name = str(item.getAttribute("android:name"))
1198 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1199 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1200 permission = UsesPermission(
1204 apk['uses-permission'].append(permission)
1206 for item in xml.getElementsByTagName('uses-permission-sdk-23'):
1207 name = str(item.getAttribute("android:name"))
1208 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1209 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1210 permission_sdk_23 = UsesPermissionSdk23(
1214 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1216 for item in xml.getElementsByTagName('uses-feature'):
1217 feature = str(item.getAttribute("android:name"))
1218 if feature != "android.hardware.screen.portrait" \
1219 and feature != "android.hardware.screen.landscape":
1220 if feature.startswith("android.feature."):
1221 feature = feature[16:]
1222 apk['features'].append(feature)
1225 def process_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1226 allow_disabled_algorithms=False, archive_bad_sig=False):
1227 """Processes the apk with the given filename in the given repo directory.
1229 This also extracts the icons.
1231 :param apkcache: current apk cache information
1232 :param apkfilename: the filename of the apk to scan
1233 :param repodir: repo directory to scan
1234 :param knownapks: known apks info
1235 :param use_date_from_apk: use date from APK (instead of current date)
1236 for newly added APKs
1237 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1238 disabled algorithms in the signature (e.g. MD5)
1239 :param archive_bad_sig: move APKs with a bad signature to the archive
1240 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1241 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1245 apkfile = os.path.join(repodir, apkfilename)
1247 cachechanged = False
1249 if apkfilename in apkcache:
1250 apk = apkcache[apkfilename]
1251 if apk.get('hash') == sha256sum(apkfile):
1252 logging.debug(_("Reading {apkfilename} from cache")
1253 .format(apkfilename=apkfilename))
1256 logging.debug(_("Ignoring stale cache data for {apkfilename}")
1257 .format(apkfilename=apkfilename))
1260 logging.debug(_("Processing {apkfilename}").format(apkfilename=apkfilename))
1263 apk = scan_apk(apkfile)
1264 except BuildException:
1265 logging.warning(_("Skipping '{apkfilename}' with invalid signature!")
1266 .format(apkfilename=apkfilename))
1267 return True, None, False
1269 # Check for debuggable apks...
1270 if common.isApkAndDebuggable(apkfile):
1271 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1273 if options.rename_apks:
1274 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1275 std_short_name = os.path.join(repodir, n)
1276 if apkfile != std_short_name:
1277 if os.path.exists(std_short_name):
1278 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1279 if apkfile != std_long_name:
1280 if os.path.exists(std_long_name):
1281 dupdir = os.path.join('duplicates', repodir)
1282 if not os.path.isdir(dupdir):
1283 os.makedirs(dupdir, exist_ok=True)
1284 dupfile = os.path.join('duplicates', std_long_name)
1285 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1286 os.rename(apkfile, dupfile)
1287 return True, None, False
1289 os.rename(apkfile, std_long_name)
1290 apkfile = std_long_name
1292 os.rename(apkfile, std_short_name)
1293 apkfile = std_short_name
1294 apkfilename = apkfile[len(repodir) + 1:]
1296 apk['apkName'] = apkfilename
1297 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1298 if os.path.exists(os.path.join(repodir, srcfilename)):
1299 apk['srcname'] = srcfilename
1301 # verify the jar signature is correct, allow deprecated
1302 # algorithms only if the APK is in the archive.
1304 if not common.verify_apk_signature(apkfile):
1305 if repodir == 'archive' or allow_disabled_algorithms:
1306 if common.verify_old_apk_signature(apkfile):
1307 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1315 logging.warning(_('Archiving {apkfilename} with invalid signature!')
1316 .format(apkfilename=apkfilename))
1317 move_apk_between_sections(repodir, 'archive', apk)
1319 logging.warning(_('Skipping {apkfilename} with invalid signature!')
1320 .format(apkfilename=apkfilename))
1321 return True, None, False
1323 apkzip = zipfile.ZipFile(apkfile, 'r')
1325 manifest = apkzip.getinfo('AndroidManifest.xml')
1326 if manifest.date_time[1] == 0: # month can't be zero
1327 logging.debug(_('AndroidManifest.xml has no date'))
1329 common.check_system_clock(datetime(*manifest.date_time), apkfilename)
1331 # extract icons from APK zip file
1332 iconfilename = "%s.%s.png" % (apk['packageName'], apk['versionCode'])
1334 empty_densities = extract_apk_icons(iconfilename, apk, apkzip, repodir)
1336 apkzip.close() # ensure that APK zip file gets closed
1338 # resize existing icons for densities missing in the APK
1339 fill_missing_icon_densities(empty_densities, iconfilename, apk, repodir)
1341 if use_date_from_apk and manifest.date_time[1] != 0:
1342 default_date_param = datetime(*manifest.date_time)
1344 default_date_param = None
1346 # Record in known apks, getting the added date at the same time..
1347 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1348 default_date=default_date_param)
1350 apk['added'] = added
1352 apkcache[apkfilename] = apk
1355 return False, apk, cachechanged
1358 def process_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1359 """Processes the apks in the given repo directory.
1361 This also extracts the icons.
1363 :param apkcache: current apk cache information
1364 :param repodir: repo directory to scan
1365 :param knownapks: known apks info
1366 :param use_date_from_apk: use date from APK (instead of current date)
1367 for newly added APKs
1368 :returns: (apks, cachechanged) where apks is a list of apk information,
1369 and cachechanged is True if the apkcache got changed.
1372 cachechanged = False
1374 for icon_dir in get_all_icon_dirs(repodir):
1375 if os.path.exists(icon_dir):
1377 shutil.rmtree(icon_dir)
1378 os.makedirs(icon_dir)
1380 os.makedirs(icon_dir)
1383 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1384 apkfilename = apkfile[len(repodir) + 1:]
1385 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1386 (skip, apk, cachethis) = process_apk(apkcache, apkfilename, repodir, knownapks,
1387 use_date_from_apk, ada, True)
1391 cachechanged = cachechanged or cachethis
1393 return apks, cachechanged
1396 def extract_apk_icons(icon_filename, apk, apkzip, repo_dir):
1398 Extracts icons from the given APK zip in various densities,
1399 saves them into given repo directory
1400 and stores their names in the APK metadata dictionary.
1402 :param icon_filename: A string representing the icon's file name
1403 :param apk: A populated dictionary containing APK metadata.
1404 Needs to have 'icons_src' key
1405 :param apkzip: An opened zipfile.ZipFile of the APK file
1406 :param repo_dir: The directory of the APK's repository
1407 :return: A list of icon densities that are missing
1409 empty_densities = []
1410 for density in screen_densities:
1411 if density not in apk['icons_src']:
1412 empty_densities.append(density)
1414 icon_src = apk['icons_src'][density]
1415 icon_dir = get_icon_dir(repo_dir, density)
1416 icon_dest = os.path.join(icon_dir, icon_filename)
1418 # Extract the icon files per density
1419 if icon_src.endswith('.xml'):
1420 png = os.path.basename(icon_src)[:-4] + '.png'
1421 for f in apkzip.namelist():
1423 m = re.match(r'res/(drawable|mipmap)-(x*[hlm]dpi).*/', f)
1424 if m and screen_resolutions[m.group(2)] == density:
1426 if icon_src.endswith('.xml'):
1427 empty_densities.append(density)
1430 with open(icon_dest, 'wb') as f:
1431 f.write(get_icon_bytes(apkzip, icon_src))
1432 apk['icons'][density] = icon_filename
1433 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1434 logging.warning("Error retrieving icon file: %s %s", icon_dest, e)
1435 del apk['icons_src'][density]
1436 empty_densities.append(density)
1438 if '-1' in apk['icons_src']:
1439 icon_src = apk['icons_src']['-1']
1440 icon_path = os.path.join(get_icon_dir(repo_dir, '0'), icon_filename)
1441 with open(icon_path, 'wb') as f:
1442 f.write(get_icon_bytes(apkzip, icon_src))
1444 im = Image.open(icon_path)
1445 dpi = px_to_dpi(im.size[0])
1446 for density in screen_densities:
1447 if density in apk['icons']:
1449 if density == screen_densities[-1] or dpi >= int(density):
1450 apk['icons'][density] = icon_filename
1451 shutil.move(icon_path,
1452 os.path.join(get_icon_dir(repo_dir, density), icon_filename))
1453 empty_densities.remove(density)
1455 except Exception as e:
1456 logging.warning(_("Failed reading {path}: {error}")
1457 .format(path=icon_path, error=e))
1460 apk['icon'] = icon_filename
1462 return empty_densities
1465 def fill_missing_icon_densities(empty_densities, icon_filename, apk, repo_dir):
1467 Resize existing icons for densities missing in the APK to ensure all densities are available
1469 :param empty_densities: A list of icon densities that are missing
1470 :param icon_filename: A string representing the icon's file name
1471 :param apk: A populated dictionary containing APK metadata. Needs to have 'icons' key
1472 :param repo_dir: The directory of the APK's repository
1474 # First try resizing down to not lose quality
1476 for density in screen_densities:
1477 if density not in empty_densities:
1478 last_density = density
1480 if last_density is None:
1482 logging.debug("Density %s not available, resizing down from %s", density, last_density)
1484 last_icon_path = os.path.join(get_icon_dir(repo_dir, last_density), icon_filename)
1485 icon_path = os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1488 fp = open(last_icon_path, 'rb')
1491 size = dpi_to_px(density)
1493 im.thumbnail((size, size), Image.ANTIALIAS)
1494 im.save(icon_path, "PNG")
1495 empty_densities.remove(density)
1496 except Exception as e:
1497 logging.warning("Invalid image file at %s: %s", last_icon_path, e)
1502 # Then just copy from the highest resolution available
1504 for density in reversed(screen_densities):
1505 if density not in empty_densities:
1506 last_density = density
1509 if last_density is None:
1513 os.path.join(get_icon_dir(repo_dir, last_density), icon_filename),
1514 os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1516 empty_densities.remove(density)
1518 for density in screen_densities:
1519 icon_dir = get_icon_dir(repo_dir, density)
1520 icon_dest = os.path.join(icon_dir, icon_filename)
1521 resize_icon(icon_dest, density)
1523 # Copy from icons-mdpi to icons since mdpi is the baseline density
1524 baseline = os.path.join(get_icon_dir(repo_dir, '160'), icon_filename)
1525 if os.path.isfile(baseline):
1526 apk['icons']['0'] = icon_filename
1527 shutil.copyfile(baseline, os.path.join(get_icon_dir(repo_dir, '0'), icon_filename))
1530 def apply_info_from_latest_apk(apps, apks):
1532 Some information from the apks needs to be applied up to the application level.
1533 When doing this, we use the info from the most recent version's apk.
1534 We deal with figuring out when the app was added and last updated at the same time.
1536 for appid, app in apps.items():
1537 bestver = UNSET_VERSION_CODE
1539 if apk['packageName'] == appid:
1540 if apk['versionCode'] > bestver:
1541 bestver = apk['versionCode']
1545 if not app.added or apk['added'] < app.added:
1546 app.added = apk['added']
1547 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1548 app.lastUpdated = apk['added']
1551 logging.debug("Don't know when " + appid + " was added")
1552 if not app.lastUpdated:
1553 logging.debug("Don't know when " + appid + " was last updated")
1555 if bestver == UNSET_VERSION_CODE:
1557 if app.Name is None:
1558 app.Name = app.AutoName or appid
1560 logging.debug("Application " + appid + " has no packages")
1562 if app.Name is None:
1563 app.Name = bestapk['name']
1564 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1565 if app.CurrentVersionCode is None:
1566 app.CurrentVersionCode = str(bestver)
1569 def make_categories_txt(repodir, categories):
1570 '''Write a category list in the repo to allow quick access'''
1572 for cat in sorted(categories):
1573 catdata += cat + '\n'
1574 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1578 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1580 def filter_apk_list_sorted(apk_list):
1582 for apk in apk_list:
1583 if apk['packageName'] == appid:
1586 # Sort the apk list by version code. First is highest/newest.
1587 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1589 for appid, app in apps.items():
1591 if app.ArchivePolicy:
1592 keepversions = int(app.ArchivePolicy[:-9])
1594 keepversions = defaultkeepversions
1596 logging.debug(_("Checking archiving for {appid} - apks:{integer}, keepversions:{keep}, archapks:{arch}")
1597 .format(appid=appid, integer=len(apks), keep=keepversions, arch=len(archapks)))
1599 current_app_apks = filter_apk_list_sorted(apks)
1600 if len(current_app_apks) > keepversions:
1601 # Move back the ones we don't want.
1602 for apk in current_app_apks[keepversions:]:
1603 move_apk_between_sections(repodir, archivedir, apk)
1604 archapks.append(apk)
1607 current_app_archapks = filter_apk_list_sorted(archapks)
1608 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1610 # Move forward the ones we want again, except DisableAlgorithm
1611 for apk in current_app_archapks:
1612 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1613 move_apk_between_sections(archivedir, repodir, apk)
1614 archapks.remove(apk)
1617 if kept == keepversions:
1621 def move_apk_between_sections(from_dir, to_dir, apk):
1622 """move an APK from repo to archive or vice versa"""
1624 def _move_file(from_dir, to_dir, filename, ignore_missing):
1625 from_path = os.path.join(from_dir, filename)
1626 if ignore_missing and not os.path.exists(from_path):
1628 to_path = os.path.join(to_dir, filename)
1629 if not os.path.exists(to_dir):
1631 shutil.move(from_path, to_path)
1633 if from_dir == to_dir:
1636 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1637 _move_file(from_dir, to_dir, apk['apkName'], False)
1638 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1639 for density in all_screen_densities:
1640 from_icon_dir = get_icon_dir(from_dir, density)
1641 to_icon_dir = get_icon_dir(to_dir, density)
1642 if density not in apk.get('icons', []):
1644 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1645 if 'srcname' in apk:
1646 _move_file(from_dir, to_dir, apk['srcname'], False)
1649 def add_apks_to_per_app_repos(repodir, apks):
1650 apks_per_app = dict()
1652 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1653 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1654 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1655 apks_per_app[apk['packageName']] = apk
1657 if not os.path.exists(apk['per_app_icons']):
1658 logging.info(_('Adding new repo for only {name}').format(name=apk['packageName']))
1659 os.makedirs(apk['per_app_icons'])
1661 apkpath = os.path.join(repodir, apk['apkName'])
1662 shutil.copy(apkpath, apk['per_app_repo'])
1663 apksigpath = apkpath + '.sig'
1664 if os.path.exists(apksigpath):
1665 shutil.copy(apksigpath, apk['per_app_repo'])
1666 apkascpath = apkpath + '.asc'
1667 if os.path.exists(apkascpath):
1668 shutil.copy(apkascpath, apk['per_app_repo'])
1671 def create_metadata_from_template(apk):
1672 '''create a new metadata file using internal or external template
1674 Generate warnings for apk's with no metadata (or create skeleton
1675 metadata files, if requested on the command line). Though the
1676 template file is YAML, this uses neither pyyaml nor ruamel.yaml
1677 since those impose things on the metadata file made from the
1678 template: field sort order, empty field value, formatting, etc.
1682 if os.path.exists('template.yml'):
1683 with open('template.yml') as f:
1685 if 'name' in apk and apk['name'] != '':
1686 metatxt = re.sub(r'''^(((Auto)?Name|Summary):)[ '"\.]*$''',
1687 r'\1 ' + apk['name'],
1689 flags=re.IGNORECASE | re.MULTILINE)
1691 logging.warning(_('{appid} does not have a name! Using package name instead.')
1692 .format(appid=apk['packageName']))
1693 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1694 r'\1 ' + apk['packageName'],
1696 flags=re.IGNORECASE | re.MULTILINE)
1697 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1701 app['Categories'] = [os.path.basename(os.getcwd())]
1702 # include some blanks as part of the template
1703 app['AuthorName'] = ''
1706 app['IssueTracker'] = ''
1707 app['SourceCode'] = ''
1708 app['CurrentVersionCode'] = 2147483647 # Java's Integer.MAX_VALUE
1709 if 'name' in apk and apk['name'] != '':
1710 app['Name'] = apk['name']
1712 logging.warning(_('{appid} does not have a name! Using package name instead.')
1713 .format(appid=apk['packageName']))
1714 app['Name'] = apk['packageName']
1715 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1716 yaml.dump(app, f, default_flow_style=False)
1717 logging.info(_("Generated skeleton metadata for {appid}").format(appid=apk['packageName']))
1726 global config, options
1728 # Parse command line...
1729 parser = ArgumentParser()
1730 common.setup_global_opts(parser)
1731 parser.add_argument("--create-key", action="store_true", default=False,
1732 help=_("Add a repo signing key to an unsigned repo"))
1733 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1734 help=_("Add skeleton metadata files for APKs that are missing them"))
1735 parser.add_argument("--delete-unknown", action="store_true", default=False,
1736 help=_("Delete APKs and/or OBBs without metadata from the repo"))
1737 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1738 help=_("Report on build data status"))
1739 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1740 help=_("Interactively ask about things that need updating."))
1741 parser.add_argument("-I", "--icons", action="store_true", default=False,
1742 help=_("Resize all the icons exceeding the max pixel size and exit"))
1743 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1744 help=_("Specify editor to use in interactive mode. Default " +
1745 "is {path}").format(path='/etc/alternatives/editor'))
1746 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1747 help=_("Update the wiki"))
1748 parser.add_argument("--pretty", action="store_true", default=False,
1749 help=_("Produce human-readable XML/JSON for index files"))
1750 parser.add_argument("--clean", action="store_true", default=False,
1751 help=_("Clean update - don't uses caches, reprocess all APKs"))
1752 parser.add_argument("--nosign", action="store_true", default=False,
1753 help=_("When configured for signed indexes, create only unsigned indexes at this stage"))
1754 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1755 help=_("Use date from APK instead of current time for newly added APKs"))
1756 parser.add_argument("--rename-apks", action="store_true", default=False,
1757 help=_("Rename APK files that do not match package.name_123.apk"))
1758 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1759 help=_("Include APKs that are signed with disabled algorithms like MD5"))
1760 metadata.add_metadata_arguments(parser)
1761 options = parser.parse_args()
1762 metadata.warnings_action = options.W
1764 config = common.read_config(options)
1766 if not ('jarsigner' in config and 'keytool' in config):
1767 raise FDroidException(_('Java JDK not found! Install in standard location or set java_paths!'))
1770 if config['archive_older'] != 0:
1771 repodirs.append('archive')
1772 if not os.path.exists('archive'):
1776 resize_all_icons(repodirs)
1779 if options.rename_apks:
1780 options.clean = True
1782 # check that icons exist now, rather than fail at the end of `fdroid update`
1783 for k in ['repo_icon', 'archive_icon']:
1785 if not os.path.exists(config[k]):
1786 logging.critical(_('{name} "{path}" does not exist! Correct it in config.py.')
1787 .format(name=k, path=config[k]))
1790 # if the user asks to create a keystore, do it now, reusing whatever it can
1791 if options.create_key:
1792 if os.path.exists(config['keystore']):
1793 logging.critical(_("Cowardily refusing to overwrite existing signing key setup!"))
1794 logging.critical("\t'" + config['keystore'] + "'")
1797 if 'repo_keyalias' not in config:
1798 config['repo_keyalias'] = socket.getfqdn()
1799 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1800 if 'keydname' not in config:
1801 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1802 common.write_to_config(config, 'keydname', config['keydname'])
1803 if 'keystore' not in config:
1804 config['keystore'] = common.default_config['keystore']
1805 common.write_to_config(config, 'keystore', config['keystore'])
1807 password = common.genpassword()
1808 if 'keystorepass' not in config:
1809 config['keystorepass'] = password
1810 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1811 if 'keypass' not in config:
1812 config['keypass'] = password
1813 common.write_to_config(config, 'keypass', config['keypass'])
1814 common.genkeystore(config)
1817 apps = metadata.read_metadata()
1819 # Generate a list of categories...
1821 for app in apps.values():
1822 categories.update(app.Categories)
1824 # Read known apks data (will be updated and written back when we've finished)
1825 knownapks = common.KnownApks()
1828 apkcache = get_cache()
1830 # Delete builds for disabled apps
1831 delete_disabled_builds(apps, apkcache, repodirs)
1833 # Scan all apks in the main repo
1834 apks, cachechanged = process_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1836 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1837 options.use_date_from_apk)
1838 cachechanged = cachechanged or fcachechanged
1841 if apk['packageName'] not in apps:
1842 if options.create_metadata:
1843 create_metadata_from_template(apk)
1844 apps = metadata.read_metadata()
1846 msg = _("{apkfilename} ({appid}) has no metadata!") \
1847 .format(apkfilename=apk['apkName'], appid=apk['packageName'])
1848 if options.delete_unknown:
1849 logging.warn(msg + '\n\t' + _("deleting: repo/{apkfilename}")
1850 .format(apkfilename=apk['apkName']))
1851 rmf = os.path.join(repodirs[0], apk['apkName'])
1852 if not os.path.exists(rmf):
1853 logging.error(_("Could not find {path} to remove it").format(path=rmf))
1857 logging.warn(msg + '\n\t' + _("Use `fdroid update -c` to create it."))
1859 copy_triple_t_store_metadata(apps)
1860 insert_obbs(repodirs[0], apps, apks)
1861 insert_localized_app_metadata(apps)
1862 translate_per_build_anti_features(apps, apks)
1864 # Scan the archive repo for apks as well
1865 if len(repodirs) > 1:
1866 archapks, cc = process_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1872 # Apply information from latest apks to the application and update dates
1873 apply_info_from_latest_apk(apps, apks + archapks)
1875 # Sort the app list by name, then the web site doesn't have to by default.
1876 # (we had to wait until we'd scanned the apks to do this, because mostly the
1877 # name comes from there!)
1878 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1880 # APKs are placed into multiple repos based on the app package, providing
1881 # per-app subscription feeds for nightly builds and things like it
1882 if config['per_app_repos']:
1883 add_apks_to_per_app_repos(repodirs[0], apks)
1884 for appid, app in apps.items():
1885 repodir = os.path.join(appid, 'fdroid', 'repo')
1887 appdict[appid] = app
1888 if os.path.isdir(repodir):
1889 index.make(appdict, [appid], apks, repodir, False)
1891 logging.info(_('Skipping index generation for {appid}').format(appid=appid))
1894 if len(repodirs) > 1:
1895 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1897 # Make the index for the main repo...
1898 index.make(apps, sortedids, apks, repodirs[0], False)
1899 make_categories_txt(repodirs[0], categories)
1901 # If there's an archive repo, make the index for it. We already scanned it
1903 if len(repodirs) > 1:
1904 index.make(apps, sortedids, archapks, repodirs[1], True)
1906 git_remote = config.get('binary_transparency_remote')
1907 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
1909 btlog.make_binary_transparency_log(repodirs)
1911 if config['update_stats']:
1912 # Update known apks info...
1913 knownapks.writeifchanged()
1915 # Generate latest apps data for widget
1916 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1918 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1920 appid = line.rstrip()
1921 data += appid + "\t"
1923 data += app.Name + "\t"
1924 if app.icon is not None:
1925 data += app.icon + "\t"
1926 data += app.License + "\n"
1927 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1931 write_cache(apkcache)
1933 # Update the wiki...
1935 update_wiki(apps, sortedids, apks + archapks)
1937 logging.info(_("Finished"))
1940 if __name__ == "__main__":