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, timedelta
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 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|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 '',
160 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
162 wikidata += app.Summary
163 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
165 wikidata += "=Description=\n"
166 wikidata += metadata.description_wiki(app.Description) + "\n"
168 wikidata += "=Maintainer Notes=\n"
169 if app.MaintainerNotes:
170 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
171 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)
173 # Get a list of all packages for this application...
175 gotcurrentver = False
179 if apk['packageName'] == appid:
180 if str(apk['versionCode']) == app.CurrentVersionCode:
183 # Include ones we can't build, as a special case...
184 for build in app.builds:
186 if build.versionCode == app.CurrentVersionCode:
188 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
189 apklist.append({'versionCode': int(build.versionCode),
190 'versionName': build.versionName,
191 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
196 if apk['versionCode'] == int(build.versionCode):
201 apklist.append({'versionCode': int(build.versionCode),
202 'versionName': build.versionName,
203 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
205 if app.CurrentVersionCode == '0':
207 # Sort with most recent first...
208 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
210 wikidata += "=Versions=\n"
211 if len(apklist) == 0:
212 wikidata += "We currently have no versions of this app available."
213 elif not gotcurrentver:
214 wikidata += "We don't have the current version of this app."
216 wikidata += "We have the current version of this app."
217 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
218 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
219 if len(app.NoSourceSince) > 0:
220 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
221 if len(app.CurrentVersion) > 0:
222 wikidata += "The current (recommended) version is " + app.CurrentVersion
223 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
226 wikidata += "==" + apk['versionName'] + "==\n"
228 if 'buildproblem' in apk:
229 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
232 wikidata += "This version is built and signed by "
234 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
236 wikidata += "the original developer.\n\n"
237 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
239 wikidata += '\n[[Category:' + wikicat + ']]\n'
240 if len(app.NoSourceSince) > 0:
241 wikidata += '\n[[Category:Apps missing source code]]\n'
242 if validapks == 0 and not app.Disabled:
243 wikidata += '\n[[Category:Apps with no packages]]\n'
244 if cantupdate and not app.Disabled:
245 wikidata += "\n[[Category:Apps we cannot update]]\n"
246 if buildfails and not app.Disabled:
247 wikidata += "\n[[Category:Apps with failing builds]]\n"
248 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
249 wikidata += '\n[[Category:Apps to Update]]\n'
251 wikidata += '\n[[Category:Apps that are disabled]]\n'
252 if app.UpdateCheckMode == 'None' and not app.Disabled:
253 wikidata += '\n[[Category:Apps with no update check]]\n'
254 for appcat in app.Categories:
255 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
257 # We can't have underscores in the page name, even if they're in
258 # the package ID, because MediaWiki messes with them...
259 pagename = appid.replace('_', ' ')
261 # Drop a trailing newline, because mediawiki is going to drop it anyway
262 # and it we don't we'll think the page has changed when it hasn't...
263 if wikidata.endswith('\n'):
264 wikidata = wikidata[:-1]
266 generated_pages[pagename] = wikidata
268 # Make a redirect from the name to the ID too, unless there's
269 # already an existing page with the name and it isn't a redirect.
271 apppagename = app.Name.replace('_', ' ')
272 apppagename = apppagename.replace('{', '')
273 apppagename = apppagename.replace('}', ' ')
274 apppagename = apppagename.replace(':', ' ')
275 apppagename = apppagename.replace('[', ' ')
276 apppagename = apppagename.replace(']', ' ')
277 # Drop double spaces caused mostly by replacing ':' above
278 apppagename = apppagename.replace(' ', ' ')
279 for expagename in site.allpages(prefix=apppagename,
280 filterredir='nonredirects',
282 if expagename == apppagename:
284 # Another reason not to make the redirect page is if the app name
285 # is the same as it's ID, because that will overwrite the real page
286 # with an redirect to itself! (Although it seems like an odd
287 # scenario this happens a lot, e.g. where there is metadata but no
288 # builds or binaries to extract a name from.
289 if apppagename == pagename:
292 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
294 for tcat, genp in [(wikicat, generated_pages),
295 (wikiredircat, generated_redirects)]:
296 catpages = site.Pages['Category:' + tcat]
298 for page in catpages:
299 existingpages.append(page.name)
300 if page.name in genp:
301 pagetxt = page.edit()
302 if pagetxt != genp[page.name]:
303 logging.debug("Updating modified page " + page.name)
304 page.save(genp[page.name], summary='Auto-updated')
306 logging.debug("Page " + page.name + " is unchanged")
308 logging.warn("Deleting page " + page.name)
309 page.delete('No longer published')
310 for pagename, text in genp.items():
311 logging.debug("Checking " + pagename)
312 if pagename not in existingpages:
313 logging.debug("Creating page " + pagename)
315 newpage = site.Pages[pagename]
316 newpage.save(text, summary='Auto-created')
317 except Exception as e:
318 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
320 # Purge server cache to ensure counts are up to date
321 site.pages['Repository Maintenance'].purge()
324 def delete_disabled_builds(apps, apkcache, repodirs):
325 """Delete disabled build outputs.
327 :param apps: list of all applications, as per metadata.read_metadata
328 :param apkcache: current apk cache information
329 :param repodirs: the repo directories to process
331 for appid, app in apps.items():
332 for build in app['builds']:
333 if not build.disable:
335 apkfilename = common.get_release_filename(app, build)
336 iconfilename = "%s.%s.png" % (
339 for repodir in repodirs:
341 os.path.join(repodir, apkfilename),
342 os.path.join(repodir, apkfilename + '.asc'),
343 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
345 for density in all_screen_densities:
346 repo_dir = get_icon_dir(repodir, density)
347 files.append(os.path.join(repo_dir, iconfilename))
350 if os.path.exists(f):
351 logging.info("Deleting disabled build output " + f)
353 if apkfilename in apkcache:
354 del apkcache[apkfilename]
357 def resize_icon(iconpath, density):
359 if not os.path.isfile(iconpath):
364 fp = open(iconpath, 'rb')
366 size = dpi_to_px(density)
368 if any(length > size for length in im.size):
370 im.thumbnail((size, size), Image.ANTIALIAS)
371 logging.debug("%s was too large at %s - new size is %s" % (
372 iconpath, oldsize, im.size))
373 im.save(iconpath, "PNG")
375 except Exception as e:
376 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
383 def resize_all_icons(repodirs):
384 """Resize all icons that exceed the max size
386 :param repodirs: the repo directories to process
388 for repodir in repodirs:
389 for density in screen_densities:
390 icon_dir = get_icon_dir(repodir, density)
391 icon_glob = os.path.join(icon_dir, '*.png')
392 for iconpath in glob.glob(icon_glob):
393 resize_icon(iconpath, density)
397 """ Get the signing certificate of an apk. To get the same md5 has that
398 Android gets, we encode the .RSA certificate in a specific format and pass
399 it hex-encoded to the md5 digest algorithm.
401 :param apkpath: path to the apk
402 :returns: A string containing the md5 of the signature of the apk or None
403 if an error occurred.
406 with zipfile.ZipFile(apkpath, 'r') as apk:
407 certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)]
410 logging.error("Found no signing certificates on %s" % apkpath)
413 logging.error("Found multiple signing certificates on %s" % apkpath)
416 cert = apk.read(certs[0])
418 cert_encoded = common.get_certificate(cert)
420 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
423 def get_cache_file():
424 return os.path.join('tmp', 'apkcache')
428 """Get the cached dict of the APK index
430 Gather information about all the apk files in the repo directory,
431 using cached data if possible. Some of the index operations take a
432 long time, like calculating the SHA-256 and verifying the APK
435 The cache is invalidated if the metadata version is different, or
436 the 'allow_disabled_algorithms' config/option is different. In
437 those cases, there is no easy way to know what has changed from
438 the cache, so just rerun the whole thing.
443 apkcachefile = get_cache_file()
444 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
445 if not options.clean and os.path.exists(apkcachefile):
446 with open(apkcachefile, 'rb') as cf:
447 apkcache = pickle.load(cf, encoding='utf-8')
448 if apkcache.get("METADATA_VERSION") != METADATA_VERSION \
449 or apkcache.get('allow_disabled_algorithms') != ada:
454 apkcache["METADATA_VERSION"] = METADATA_VERSION
455 apkcache['allow_disabled_algorithms'] = ada
460 def write_cache(apkcache):
461 apkcachefile = get_cache_file()
462 cache_path = os.path.dirname(apkcachefile)
463 if not os.path.exists(cache_path):
464 os.makedirs(cache_path)
465 with open(apkcachefile, 'wb') as cf:
466 pickle.dump(apkcache, cf)
469 def get_icon_bytes(apkzip, iconsrc):
470 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
472 return apkzip.read(iconsrc)
474 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
477 def sha256sum(filename):
478 '''Calculate the sha256 of the given file'''
479 sha = hashlib.sha256()
480 with open(filename, 'rb') as f:
486 return sha.hexdigest()
489 def has_known_vulnerability(filename):
490 """checks for known vulnerabilities in the APK
492 Checks OpenSSL .so files in the APK to see if they are a known vulnerable
493 version. Google also enforces this:
494 https://support.google.com/faqs/answer/6376725?hl=en
496 Checks whether there are more than one classes.dex or AndroidManifest.xml
497 files, which is invalid and an essential part of the "Master Key" attack.
499 http://www.saurik.com/id/17
502 # statically load this pattern
503 if not hasattr(has_known_vulnerability, "pattern"):
504 has_known_vulnerability.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
507 with zipfile.ZipFile(filename) as zf:
508 for name in zf.namelist():
509 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
512 chunk = lib.read(4096)
515 m = has_known_vulnerability.pattern.search(chunk)
517 version = m.group(1).decode('ascii')
518 if (version.startswith('1.0.1') and len(version) > 5 and version[5] >= 'r') \
519 or (version.startswith('1.0.2') and len(version) > 5 and version[5] >= 'f') \
520 or re.match(r'[1-9]\.[1-9]\.[0-9].*', version):
521 logging.debug('"%s" contains recent %s (%s)', filename, name, version)
523 logging.warning('"%s" contains outdated %s (%s)', filename, name, version)
526 elif name == 'AndroidManifest.xml' or name == 'classes.dex' or name.endswith('.so'):
527 if name in files_in_apk:
529 files_in_apk.add(name)
534 def insert_obbs(repodir, apps, apks):
535 """Scans the .obb files in a given repo directory and adds them to the
536 relevant APK instances. OBB files have versionCodes like APK
537 files, and they are loosely associated. If there is an OBB file
538 present, then any APK with the same or higher versionCode will use
539 that OBB file. There are two OBB types: main and patch, each APK
540 can only have only have one of each.
542 https://developer.android.com/google/play/expansion-files.html
544 :param repodir: repo directory to scan
545 :param apps: list of current, valid apps
546 :param apks: current information on all APKs
550 def obbWarnDelete(f, msg):
551 logging.warning(msg + f)
552 if options.delete_unknown:
553 logging.error("Deleting unknown file: " + f)
557 java_Integer_MIN_VALUE = -pow(2, 31)
558 currentPackageNames = apps.keys()
559 for f in glob.glob(os.path.join(repodir, '*.obb')):
560 obbfile = os.path.basename(f)
561 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
562 chunks = obbfile.split('.')
563 if chunks[0] != 'main' and chunks[0] != 'patch':
564 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
566 if not re.match(r'^-?[0-9]+$', chunks[1]):
567 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
569 versionCode = int(chunks[1])
570 packagename = ".".join(chunks[2:-1])
572 highestVersionCode = java_Integer_MIN_VALUE
573 if packagename not in currentPackageNames:
574 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
577 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
578 highestVersionCode = apk['versionCode']
579 if versionCode > highestVersionCode:
580 obbWarnDelete(f, 'OBB file has newer versionCode(' + str(versionCode)
581 + ') than any APK: ')
583 obbsha256 = sha256sum(f)
584 obbs.append((packagename, versionCode, obbfile, obbsha256))
587 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
588 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
589 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
590 apk['obbMainFile'] = obbfile
591 apk['obbMainFileSha256'] = obbsha256
592 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
593 apk['obbPatchFile'] = obbfile
594 apk['obbPatchFileSha256'] = obbsha256
595 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
599 def translate_per_build_anti_features(apps, apks):
600 """Grab the anti-features list from the build metadata
602 For most Anti-Features, they are really most applicable per-APK,
603 not for an app. An app can fix a vulnerability, add/remove
604 tracking, etc. This reads the 'antifeatures' list from the Build
605 entries in the fdroiddata metadata file, then transforms it into
606 the 'antiFeatures' list of unique items for the index.
608 The field key is all lower case in the metadata file to match the
609 rest of the Build fields. It is 'antiFeatures' camel case in the
610 implementation, index, and fdroidclient since it is translated
611 from the build 'antifeatures' field, not directly included.
615 antiFeatures = dict()
616 for packageName, app in apps.items():
618 for build in app['builds']:
619 afl = build.get('antifeatures')
621 d[int(build.versionCode)] = afl
623 antiFeatures[packageName] = d
626 d = antiFeatures.get(apk['packageName'])
628 afl = d.get(apk['versionCode'])
630 apk['antiFeatures'].update(afl)
633 def _get_localized_dict(app, locale):
634 '''get the dict to add localized store metadata to'''
635 if 'localized' not in app:
636 app['localized'] = collections.OrderedDict()
637 if locale not in app['localized']:
638 app['localized'][locale] = collections.OrderedDict()
639 return app['localized'][locale]
642 def _set_localized_text_entry(app, locale, key, f):
643 limit = config['char_limits'][key]
644 localized = _get_localized_dict(app, locale)
646 text = fp.read()[:limit]
648 localized[key] = text
651 def _set_author_entry(app, key, f):
652 limit = config['char_limits']['author']
654 text = fp.read()[:limit]
659 def copy_triple_t_store_metadata(apps):
660 """Include store metadata from the app's source repo
662 The Triple-T Gradle Play Publisher is a plugin that has a standard
663 file layout for all of the metadata and graphics that the Google
664 Play Store accepts. Since F-Droid has the git repo, it can just
665 pluck those files directly. This method reads any text files into
666 the app dict, then copies any graphics into the fdroid repo
669 This needs to be run before insert_localized_app_metadata() so that
670 the graphics files that are copied into the fdroid repo get
673 https://github.com/Triple-T/gradle-play-publisher#upload-images
674 https://github.com/Triple-T/gradle-play-publisher#play-store-metadata
678 if not os.path.isdir('build'):
679 return # nothing to do
681 for packageName, app in apps.items():
682 for d in glob.glob(os.path.join('build', packageName, '*', 'src', '*', 'play')):
683 logging.debug('Triple-T Gradle Play Publisher: ' + d)
684 for root, dirs, files in os.walk(d):
685 segments = root.split('/')
686 locale = segments[-2]
688 if f == 'fulldescription':
689 _set_localized_text_entry(app, locale, 'description',
690 os.path.join(root, f))
692 elif f == 'shortdescription':
693 _set_localized_text_entry(app, locale, 'summary',
694 os.path.join(root, f))
697 _set_localized_text_entry(app, locale, 'name',
698 os.path.join(root, f))
701 _set_localized_text_entry(app, locale, 'video',
702 os.path.join(root, f))
704 elif f == 'whatsnew':
705 _set_localized_text_entry(app, segments[-1], 'whatsNew',
706 os.path.join(root, f))
708 elif f == 'contactEmail':
709 _set_author_entry(app, 'authorEmail', os.path.join(root, f))
711 elif f == 'contactPhone':
712 _set_author_entry(app, 'authorPhone', os.path.join(root, f))
714 elif f == 'contactWebsite':
715 _set_author_entry(app, 'authorWebSite', os.path.join(root, f))
718 base, extension = common.get_extension(f)
719 dirname = os.path.basename(root)
720 if dirname in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
721 if segments[-2] == 'listing':
722 locale = segments[-3]
724 locale = segments[-2]
725 destdir = os.path.join('repo', packageName, locale)
726 os.makedirs(destdir, mode=0o755, exist_ok=True)
727 sourcefile = os.path.join(root, f)
728 destfile = os.path.join(destdir, dirname + '.' + extension)
729 logging.debug('copying ' + sourcefile + ' ' + destfile)
730 shutil.copy(sourcefile, destfile)
733 def insert_localized_app_metadata(apps):
734 """scans standard locations for graphics and localized text
736 Scans for localized description files, store graphics, and
737 screenshot PNG files in statically defined screenshots directory
738 and adds them to the app metadata. The screenshots and graphic
739 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
740 and must be in the following layout:
741 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
743 repo/packageName/locale/featureGraphic.png
744 repo/packageName/locale/phoneScreenshots/1.png
745 repo/packageName/locale/phoneScreenshots/2.png
747 The changelog files must be text files named with the versionCode
748 ending with ".txt" and must be in the following layout:
749 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
751 repo/packageName/locale/changelogs/12345.txt
753 This will scan the each app's source repo then the metadata/ dir
754 for these standard locations of changelog files. If it finds
755 them, they will be added to the dict of all packages, with the
756 versions in the metadata/ folder taking precendence over the what
757 is in the app's source repo.
759 Where "packageName" is the app's packageName and "locale" is the locale
760 of the graphics, e.g. what language they are in, using the IETF RFC5646
761 format (en-US, fr-CA, es-MX, etc).
763 This will also scan the app's git for a fastlane folder, and the
764 metadata/ folder and the apps' source repos for standard locations
765 of graphic and screenshot files. If it finds them, it will copy
766 them into the repo. The fastlane files follow this pattern:
767 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
771 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
772 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
773 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
775 for srcd in sorted(sourcedirs):
776 if not os.path.isdir(srcd):
778 for root, dirs, files in os.walk(srcd):
779 segments = root.split('/')
780 packageName = segments[1]
781 if packageName not in apps:
782 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
784 locale = segments[-1]
785 destdir = os.path.join('repo', packageName, locale)
787 if f in ('description.txt', 'full_description.txt'):
788 _set_localized_text_entry(apps[packageName], locale, 'description',
789 os.path.join(root, f))
791 elif f in ('summary.txt', 'short_description.txt'):
792 _set_localized_text_entry(apps[packageName], locale, 'summary',
793 os.path.join(root, f))
795 elif f in ('name.txt', 'title.txt'):
796 _set_localized_text_entry(apps[packageName], locale, 'name',
797 os.path.join(root, f))
799 elif f == 'video.txt':
800 _set_localized_text_entry(apps[packageName], locale, 'video',
801 os.path.join(root, f))
803 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
804 locale = segments[-2]
805 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
806 os.path.join(root, f))
809 base, extension = common.get_extension(f)
810 if locale == 'images':
811 locale = segments[-2]
812 destdir = os.path.join('repo', packageName, locale)
813 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
814 os.makedirs(destdir, mode=0o755, exist_ok=True)
815 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
816 shutil.copy(os.path.join(root, f), destdir)
818 if d in SCREENSHOT_DIRS:
819 for f in glob.glob(os.path.join(root, d, '*.*')):
820 _, extension = common.get_extension(f)
821 if extension in ALLOWED_EXTENSIONS:
822 screenshotdestdir = os.path.join(destdir, d)
823 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
824 logging.debug('copying ' + f + ' ' + screenshotdestdir)
825 shutil.copy(f, screenshotdestdir)
827 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
829 if not os.path.isdir(d):
831 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
832 if not os.path.isfile(f):
834 segments = f.split('/')
835 packageName = segments[1]
837 screenshotdir = segments[3]
838 filename = os.path.basename(f)
839 base, extension = common.get_extension(filename)
841 if packageName not in apps:
842 logging.warning('Found "%s" graphic without metadata for app "%s"!'
843 % (filename, packageName))
845 graphics = _get_localized_dict(apps[packageName], locale)
847 if extension not in ALLOWED_EXTENSIONS:
848 logging.warning('Only PNG and JPEG are supported for graphics, found: ' + f)
849 elif base in GRAPHIC_NAMES:
850 # there can only be zero or one of these per locale
851 graphics[base] = filename
852 elif screenshotdir in SCREENSHOT_DIRS:
853 # there can any number of these per locale
854 logging.debug('adding to ' + screenshotdir + ': ' + f)
855 if screenshotdir not in graphics:
856 graphics[screenshotdir] = []
857 graphics[screenshotdir].append(filename)
859 logging.warning('Unsupported graphics file found: ' + f)
862 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
863 """Scan a repo for all files with an extension except APK/OBB
865 :param apkcache: current cached info about all repo files
866 :param repodir: repo directory to scan
867 :param knownapks: list of all known files, as per metadata.read_metadata
868 :param use_date_from_file: use date from file (instead of current date)
869 for newly added files
874 repodir = repodir.encode('utf-8')
875 for name in os.listdir(repodir):
876 file_extension = common.get_file_extension(name)
877 if file_extension == 'apk' or file_extension == 'obb':
879 filename = os.path.join(repodir, name)
880 name_utf8 = name.decode('utf-8')
881 if filename.endswith(b'_src.tar.gz'):
882 logging.debug('skipping source tarball: ' + filename.decode('utf-8'))
884 if not common.is_repo_file(filename):
886 stat = os.stat(filename)
887 if stat.st_size == 0:
888 raise FDroidException(filename + ' is zero size!')
890 shasum = sha256sum(filename)
893 repo_file = apkcache[name]
894 # added time is cached as tuple but used here as datetime instance
895 if 'added' in repo_file:
896 a = repo_file['added']
897 if isinstance(a, datetime):
898 repo_file['added'] = a
900 repo_file['added'] = datetime(*a[:6])
901 if repo_file.get('hash') == shasum:
902 logging.debug("Reading " + name_utf8 + " from cache")
905 logging.debug("Ignoring stale cache data for " + name_utf8)
908 logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8))
909 repo_file = collections.OrderedDict()
910 repo_file['name'] = os.path.splitext(name_utf8)[0]
911 # TODO rename apkname globally to something more generic
912 repo_file['apkName'] = name_utf8
913 repo_file['hash'] = shasum
914 repo_file['hashType'] = 'sha256'
915 repo_file['versionCode'] = 0
916 repo_file['versionName'] = shasum
917 # the static ID is the SHA256 unless it is set in the metadata
918 repo_file['packageName'] = shasum
920 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
922 repo_file['packageName'] = m.group(1)
923 repo_file['versionCode'] = int(m.group(2))
924 srcfilename = name + b'_src.tar.gz'
925 if os.path.exists(os.path.join(repodir, srcfilename)):
926 repo_file['srcname'] = srcfilename.decode('utf-8')
927 repo_file['size'] = stat.st_size
929 apkcache[name] = repo_file
932 if use_date_from_file:
933 timestamp = stat.st_ctime
934 default_date_param = time.gmtime(time.mktime(datetime.fromtimestamp(timestamp).timetuple()))
936 default_date_param = None
938 # Record in knownapks, getting the added date at the same time..
939 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
940 default_date=default_date_param)
942 repo_file['added'] = added
944 repo_files.append(repo_file)
946 return repo_files, cachechanged
949 def scan_apk(apk_file):
951 Scans an APK file and returns dictionary with metadata of the APK.
953 Attention: This does *not* verify that the APK signature is correct.
955 :param apk_file: The (ideally absolute) path to the APK file
956 :raises BuildException
957 :return A dict containing APK metadata
960 'hash': sha256sum(apk_file),
961 'hashType': 'sha256',
962 'uses-permission': [],
963 'uses-permission-sdk-23': [],
967 'antiFeatures': set(),
970 if SdkToolsPopen(['aapt', 'version'], output=False):
971 scan_apk_aapt(apk, apk_file)
973 scan_apk_androguard(apk, apk_file)
975 # Get the signature, or rather the signing key fingerprints
976 logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
977 apk['sig'] = getsig(apk_file)
979 raise BuildException("Failed to get apk signature")
980 apk['signer'] = common.apk_signer_fingerprint(os.path.join(os.getcwd(),
982 if not apk.get('signer'):
983 raise BuildException("Failed to get apk signing key fingerprint")
985 # Get size of the APK
986 apk['size'] = os.path.getsize(apk_file)
988 if 'minSdkVersion' not in apk:
989 logging.warning("No SDK version information found in {0}".format(apk_file))
990 apk['minSdkVersion'] = 1
992 # Check for known vulnerabilities
993 if has_known_vulnerability(apk_file):
994 apk['antiFeatures'].add('KnownVuln')
999 def scan_apk_aapt(apk, apkfile):
1000 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1001 if p.returncode != 0:
1002 if options.delete_unknown:
1003 if os.path.exists(apkfile):
1004 logging.error("Failed to get apk information, deleting " + apkfile)
1007 logging.error("Could not find {0} to remove it".format(apkfile))
1009 logging.error("Failed to get apk information, skipping " + apkfile)
1010 raise BuildException("Invalid APK")
1011 for line in p.output.splitlines():
1012 if line.startswith("package:"):
1014 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
1015 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
1016 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
1017 except Exception as e:
1018 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
1019 elif line.startswith("application:"):
1020 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1021 # Keep path to non-dpi icon in case we need it
1022 match = re.match(APK_ICON_PAT_NODPI, line)
1024 apk['icons_src']['-1'] = match.group(1)
1025 elif line.startswith("launchable-activity:"):
1026 # Only use launchable-activity as fallback to application
1028 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1029 if '-1' not in apk['icons_src']:
1030 match = re.match(APK_ICON_PAT_NODPI, line)
1032 apk['icons_src']['-1'] = match.group(1)
1033 elif line.startswith("application-icon-"):
1034 match = re.match(APK_ICON_PAT, line)
1036 density = match.group(1)
1037 path = match.group(2)
1038 apk['icons_src'][density] = path
1039 elif line.startswith("sdkVersion:"):
1040 m = re.match(APK_SDK_VERSION_PAT, line)
1042 logging.error(line.replace('sdkVersion:', '')
1043 + ' is not a valid minSdkVersion!')
1045 apk['minSdkVersion'] = m.group(1)
1046 # if target not set, default to min
1047 if 'targetSdkVersion' not in apk:
1048 apk['targetSdkVersion'] = m.group(1)
1049 elif line.startswith("targetSdkVersion:"):
1050 m = re.match(APK_SDK_VERSION_PAT, line)
1052 logging.error(line.replace('targetSdkVersion:', '')
1053 + ' is not a valid targetSdkVersion!')
1055 apk['targetSdkVersion'] = m.group(1)
1056 elif line.startswith("maxSdkVersion:"):
1057 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
1058 elif line.startswith("native-code:"):
1059 apk['nativecode'] = []
1060 for arch in line[13:].split(' '):
1061 apk['nativecode'].append(arch[1:-1])
1062 elif line.startswith('uses-permission:'):
1063 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1064 if perm_match['maxSdkVersion']:
1065 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1066 permission = UsesPermission(
1068 perm_match['maxSdkVersion']
1071 apk['uses-permission'].append(permission)
1072 elif line.startswith('uses-permission-sdk-23:'):
1073 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1074 if perm_match['maxSdkVersion']:
1075 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1076 permission_sdk_23 = UsesPermissionSdk23(
1078 perm_match['maxSdkVersion']
1081 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1083 elif line.startswith('uses-feature:'):
1084 feature = re.match(APK_FEATURE_PAT, line).group(1)
1085 # Filter out this, it's only added with the latest SDK tools and
1086 # causes problems for lots of apps.
1087 if feature != "android.hardware.screen.portrait" \
1088 and feature != "android.hardware.screen.landscape":
1089 if feature.startswith("android.feature."):
1090 feature = feature[16:]
1091 apk['features'].add(feature)
1094 def scan_apk_androguard(apk, apkfile):
1096 from androguard.core.bytecodes.apk import APK
1097 apkobject = APK(apkfile)
1098 if apkobject.is_valid_APK():
1099 arsc = apkobject.get_android_resources()
1101 if options.delete_unknown:
1102 if os.path.exists(apkfile):
1103 logging.error("Failed to get apk information, deleting " + apkfile)
1106 logging.error("Could not find {0} to remove it".format(apkfile))
1108 logging.error("Failed to get apk information, skipping " + apkfile)
1109 raise BuildException("Invaild APK")
1111 raise FDroidException("androguard library is not installed and aapt not present")
1112 except FileNotFoundError:
1113 logging.error("Could not open apk file for analysis")
1114 raise BuildException("Invalid APK")
1116 apk['packageName'] = apkobject.get_package()
1117 apk['versionCode'] = int(apkobject.get_androidversion_code())
1118 apk['versionName'] = apkobject.get_androidversion_name()
1119 if apk['versionName'][0] == "@":
1120 version_id = int(apk['versionName'].replace("@", "0x"), 16)
1121 version_id = arsc.get_id(apk['packageName'], version_id)[1]
1122 apk['versionName'] = arsc.get_string(apk['packageName'], version_id)[1]
1123 apk['name'] = apkobject.get_app_name()
1125 if apkobject.get_max_sdk_version() is not None:
1126 apk['maxSdkVersion'] = apkobject.get_max_sdk_version()
1127 apk['minSdkVersion'] = apkobject.get_min_sdk_version()
1128 apk['targetSdkVersion'] = apkobject.get_target_sdk_version()
1130 icon_id = int(apkobject.get_element("application", "icon").replace("@", "0x"), 16)
1131 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1133 density_re = re.compile("^res/(.*)/" + icon_name + ".*$")
1135 for file in apkobject.get_files():
1136 d_re = density_re.match(file)
1138 folder = d_re.group(1).split('-')
1140 resolution = folder[1]
1143 density = screen_resolutions[resolution]
1144 apk['icons_src'][density] = d_re.group(0)
1146 if apk['icons_src'].get('-1') is None:
1147 apk['icons_src']['-1'] = apk['icons_src']['160']
1149 arch_re = re.compile("^lib/(.*)/.*$")
1150 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1152 apk['nativecode'] = []
1153 apk['nativecode'].extend(sorted(list(arch)))
1155 xml = apkobject.get_android_manifest_xml()
1157 for item in xml.getElementsByTagName('uses-permission'):
1158 name = str(item.getAttribute("android:name"))
1159 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1160 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1161 permission = UsesPermission(
1165 apk['uses-permission'].append(permission)
1167 for item in xml.getElementsByTagName('uses-permission-sdk-23'):
1168 name = str(item.getAttribute("android:name"))
1169 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1170 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1171 permission_sdk_23 = UsesPermissionSdk23(
1175 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1177 for item in xml.getElementsByTagName('uses-feature'):
1178 feature = str(item.getAttribute("android:name"))
1179 if feature != "android.hardware.screen.portrait" \
1180 and feature != "android.hardware.screen.landscape":
1181 if feature.startswith("android.feature."):
1182 feature = feature[16:]
1183 apk['features'].append(feature)
1186 def process_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1187 allow_disabled_algorithms=False, archive_bad_sig=False):
1188 """Processes the apk with the given filename in the given repo directory.
1190 This also extracts the icons.
1192 :param apkcache: current apk cache information
1193 :param apkfilename: the filename of the apk to scan
1194 :param repodir: repo directory to scan
1195 :param knownapks: known apks info
1196 :param use_date_from_apk: use date from APK (instead of current date)
1197 for newly added APKs
1198 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1199 disabled algorithms in the signature (e.g. MD5)
1200 :param archive_bad_sig: move APKs with a bad signature to the archive
1201 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1202 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1206 apkfile = os.path.join(repodir, apkfilename)
1208 cachechanged = False
1210 if apkfilename in apkcache:
1211 apk = apkcache[apkfilename]
1212 if apk.get('hash') == sha256sum(apkfile):
1213 logging.debug("Reading " + apkfilename + " from cache")
1216 logging.debug("Ignoring stale cache data for " + apkfilename)
1219 logging.debug(_("Processing {apkfilename}").format(apkfilename=apkfilename))
1222 apk = scan_apk(apkfile)
1223 except BuildException:
1224 logging.warning(_("Skipping '{apkfilename}' with invalid signature!")
1225 .format(apkfilename=apkfilename))
1226 return True, None, False
1228 # Check for debuggable apks...
1229 if common.isApkAndDebuggable(apkfile):
1230 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1232 if options.rename_apks:
1233 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1234 std_short_name = os.path.join(repodir, n)
1235 if apkfile != std_short_name:
1236 if os.path.exists(std_short_name):
1237 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1238 if apkfile != std_long_name:
1239 if os.path.exists(std_long_name):
1240 dupdir = os.path.join('duplicates', repodir)
1241 if not os.path.isdir(dupdir):
1242 os.makedirs(dupdir, exist_ok=True)
1243 dupfile = os.path.join('duplicates', std_long_name)
1244 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1245 os.rename(apkfile, dupfile)
1246 return True, None, False
1248 os.rename(apkfile, std_long_name)
1249 apkfile = std_long_name
1251 os.rename(apkfile, std_short_name)
1252 apkfile = std_short_name
1253 apkfilename = apkfile[len(repodir) + 1:]
1255 apk['apkName'] = apkfilename
1256 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1257 if os.path.exists(os.path.join(repodir, srcfilename)):
1258 apk['srcname'] = srcfilename
1260 # verify the jar signature is correct, allow deprecated
1261 # algorithms only if the APK is in the archive.
1263 if not common.verify_apk_signature(apkfile):
1264 if repodir == 'archive' or allow_disabled_algorithms:
1265 if common.verify_old_apk_signature(apkfile):
1266 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1274 logging.warning('Archiving "' + apkfilename + '" with invalid signature!')
1275 move_apk_between_sections(repodir, 'archive', apk)
1277 logging.warning('Skipping "' + apkfilename + '" with invalid signature!')
1278 return True, None, False
1280 apkzip = zipfile.ZipFile(apkfile, 'r')
1282 # if an APK has files newer than the system time, suggest updating
1283 # the system clock. This is useful for offline systems, used for
1284 # signing, which do not have another source of clock sync info. It
1285 # has to be more than 24 hours newer because ZIP/APK files do not
1286 # store timezone info
1287 manifest = apkzip.getinfo('AndroidManifest.xml')
1288 if manifest.date_time[1] == 0: # month can't be zero
1289 logging.debug('AndroidManifest.xml has no date')
1291 dt_obj = datetime(*manifest.date_time)
1292 checkdt = dt_obj - timedelta(1)
1293 if datetime.today() < checkdt:
1294 logging.warning('System clock is older than manifest in: '
1296 + '\nSet clock to that time using:\n'
1297 + 'sudo date -s "' + str(dt_obj) + '"')
1299 # extract icons from APK zip file
1300 iconfilename = "%s.%s.png" % (apk['packageName'], apk['versionCode'])
1302 empty_densities = extract_apk_icons(iconfilename, apk, apkzip, repodir)
1304 apkzip.close() # ensure that APK zip file gets closed
1306 # resize existing icons for densities missing in the APK
1307 fill_missing_icon_densities(empty_densities, iconfilename, apk, repodir)
1309 if use_date_from_apk and manifest.date_time[1] != 0:
1310 default_date_param = datetime(*manifest.date_time)
1312 default_date_param = None
1314 # Record in known apks, getting the added date at the same time..
1315 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1316 default_date=default_date_param)
1318 apk['added'] = added
1320 apkcache[apkfilename] = apk
1323 return False, apk, cachechanged
1326 def process_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1327 """Processes the apks in the given repo directory.
1329 This also extracts the icons.
1331 :param apkcache: current apk cache information
1332 :param repodir: repo directory to scan
1333 :param knownapks: known apks info
1334 :param use_date_from_apk: use date from APK (instead of current date)
1335 for newly added APKs
1336 :returns: (apks, cachechanged) where apks is a list of apk information,
1337 and cachechanged is True if the apkcache got changed.
1340 cachechanged = False
1342 for icon_dir in get_all_icon_dirs(repodir):
1343 if os.path.exists(icon_dir):
1345 shutil.rmtree(icon_dir)
1346 os.makedirs(icon_dir)
1348 os.makedirs(icon_dir)
1351 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1352 apkfilename = apkfile[len(repodir) + 1:]
1353 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1354 (skip, apk, cachethis) = process_apk(apkcache, apkfilename, repodir, knownapks,
1355 use_date_from_apk, ada, True)
1359 cachechanged = cachechanged or cachethis
1361 return apks, cachechanged
1364 def extract_apk_icons(icon_filename, apk, apkzip, repo_dir):
1366 Extracts icons from the given APK zip in various densities,
1367 saves them into given repo directory
1368 and stores their names in the APK metadata dictionary.
1370 :param icon_filename: A string representing the icon's file name
1371 :param apk: A populated dictionary containing APK metadata.
1372 Needs to have 'icons_src' key
1373 :param apkzip: An opened zipfile.ZipFile of the APK file
1374 :param repo_dir: The directory of the APK's repository
1375 :return: A list of icon densities that are missing
1377 empty_densities = []
1378 for density in screen_densities:
1379 if density not in apk['icons_src']:
1380 empty_densities.append(density)
1382 icon_src = apk['icons_src'][density]
1383 icon_dir = get_icon_dir(repo_dir, density)
1384 icon_dest = os.path.join(icon_dir, icon_filename)
1386 # Extract the icon files per density
1387 if icon_src.endswith('.xml'):
1388 png = os.path.basename(icon_src)[:-4] + '.png'
1389 for f in apkzip.namelist():
1391 m = re.match(r'res/(drawable|mipmap)-(x*[hlm]dpi).*/', f)
1392 if m and screen_resolutions[m.group(2)] == density:
1394 if icon_src.endswith('.xml'):
1395 empty_densities.append(density)
1398 with open(icon_dest, 'wb') as f:
1399 f.write(get_icon_bytes(apkzip, icon_src))
1400 apk['icons'][density] = icon_filename
1401 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1402 logging.warning("Error retrieving icon file: %s %s", icon_dest, e)
1403 del apk['icons_src'][density]
1404 empty_densities.append(density)
1406 if '-1' in apk['icons_src']:
1407 icon_src = apk['icons_src']['-1']
1408 icon_path = os.path.join(get_icon_dir(repo_dir, '0'), icon_filename)
1409 with open(icon_path, 'wb') as f:
1410 f.write(get_icon_bytes(apkzip, icon_src))
1412 im = Image.open(icon_path)
1413 dpi = px_to_dpi(im.size[0])
1414 for density in screen_densities:
1415 if density in apk['icons']:
1417 if density == screen_densities[-1] or dpi >= int(density):
1418 apk['icons'][density] = icon_filename
1419 shutil.move(icon_path,
1420 os.path.join(get_icon_dir(repo_dir, density), icon_filename))
1421 empty_densities.remove(density)
1423 except Exception as e:
1424 logging.warning("Failed reading {0} - {1}".format(icon_path, e))
1427 apk['icon'] = icon_filename
1429 return empty_densities
1432 def fill_missing_icon_densities(empty_densities, icon_filename, apk, repo_dir):
1434 Resize existing icons for densities missing in the APK to ensure all densities are available
1436 :param empty_densities: A list of icon densities that are missing
1437 :param icon_filename: A string representing the icon's file name
1438 :param apk: A populated dictionary containing APK metadata. Needs to have 'icons' key
1439 :param repo_dir: The directory of the APK's repository
1441 # First try resizing down to not lose quality
1443 for density in screen_densities:
1444 if density not in empty_densities:
1445 last_density = density
1447 if last_density is None:
1449 logging.debug("Density %s not available, resizing down from %s", density, last_density)
1451 last_icon_path = os.path.join(get_icon_dir(repo_dir, last_density), icon_filename)
1452 icon_path = os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1455 fp = open(last_icon_path, 'rb')
1458 size = dpi_to_px(density)
1460 im.thumbnail((size, size), Image.ANTIALIAS)
1461 im.save(icon_path, "PNG")
1462 empty_densities.remove(density)
1463 except Exception as e:
1464 logging.warning("Invalid image file at %s: %s", last_icon_path, e)
1469 # Then just copy from the highest resolution available
1471 for density in reversed(screen_densities):
1472 if density not in empty_densities:
1473 last_density = density
1476 if last_density is None:
1480 os.path.join(get_icon_dir(repo_dir, last_density), icon_filename),
1481 os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1483 empty_densities.remove(density)
1485 for density in screen_densities:
1486 icon_dir = get_icon_dir(repo_dir, density)
1487 icon_dest = os.path.join(icon_dir, icon_filename)
1488 resize_icon(icon_dest, density)
1490 # Copy from icons-mdpi to icons since mdpi is the baseline density
1491 baseline = os.path.join(get_icon_dir(repo_dir, '160'), icon_filename)
1492 if os.path.isfile(baseline):
1493 apk['icons']['0'] = icon_filename
1494 shutil.copyfile(baseline, os.path.join(get_icon_dir(repo_dir, '0'), icon_filename))
1497 def apply_info_from_latest_apk(apps, apks):
1499 Some information from the apks needs to be applied up to the application level.
1500 When doing this, we use the info from the most recent version's apk.
1501 We deal with figuring out when the app was added and last updated at the same time.
1503 for appid, app in apps.items():
1504 bestver = UNSET_VERSION_CODE
1506 if apk['packageName'] == appid:
1507 if apk['versionCode'] > bestver:
1508 bestver = apk['versionCode']
1512 if not app.added or apk['added'] < app.added:
1513 app.added = apk['added']
1514 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1515 app.lastUpdated = apk['added']
1518 logging.debug("Don't know when " + appid + " was added")
1519 if not app.lastUpdated:
1520 logging.debug("Don't know when " + appid + " was last updated")
1522 if bestver == UNSET_VERSION_CODE:
1524 if app.Name is None:
1525 app.Name = app.AutoName or appid
1527 logging.debug("Application " + appid + " has no packages")
1529 if app.Name is None:
1530 app.Name = bestapk['name']
1531 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1532 if app.CurrentVersionCode is None:
1533 app.CurrentVersionCode = str(bestver)
1536 def make_categories_txt(repodir, categories):
1537 '''Write a category list in the repo to allow quick access'''
1539 for cat in sorted(categories):
1540 catdata += cat + '\n'
1541 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1545 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1547 def filter_apk_list_sorted(apk_list):
1549 for apk in apk_list:
1550 if apk['packageName'] == appid:
1553 # Sort the apk list by version code. First is highest/newest.
1554 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1556 for appid, app in apps.items():
1558 if app.ArchivePolicy:
1559 keepversions = int(app.ArchivePolicy[:-9])
1561 keepversions = defaultkeepversions
1563 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1564 .format(appid, len(apks), keepversions, len(archapks)))
1566 current_app_apks = filter_apk_list_sorted(apks)
1567 if len(current_app_apks) > keepversions:
1568 # Move back the ones we don't want.
1569 for apk in current_app_apks[keepversions:]:
1570 move_apk_between_sections(repodir, archivedir, apk)
1571 archapks.append(apk)
1574 current_app_archapks = filter_apk_list_sorted(archapks)
1575 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1577 # Move forward the ones we want again, except DisableAlgorithm
1578 for apk in current_app_archapks:
1579 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1580 move_apk_between_sections(archivedir, repodir, apk)
1581 archapks.remove(apk)
1584 if kept == keepversions:
1588 def move_apk_between_sections(from_dir, to_dir, apk):
1589 """move an APK from repo to archive or vice versa"""
1591 def _move_file(from_dir, to_dir, filename, ignore_missing):
1592 from_path = os.path.join(from_dir, filename)
1593 if ignore_missing and not os.path.exists(from_path):
1595 to_path = os.path.join(to_dir, filename)
1596 if not os.path.exists(to_dir):
1598 shutil.move(from_path, to_path)
1600 if from_dir == to_dir:
1603 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1604 _move_file(from_dir, to_dir, apk['apkName'], False)
1605 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1606 for density in all_screen_densities:
1607 from_icon_dir = get_icon_dir(from_dir, density)
1608 to_icon_dir = get_icon_dir(to_dir, density)
1609 if density not in apk['icons']:
1611 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1612 if 'srcname' in apk:
1613 _move_file(from_dir, to_dir, apk['srcname'], False)
1616 def add_apks_to_per_app_repos(repodir, apks):
1617 apks_per_app = dict()
1619 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1620 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1621 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1622 apks_per_app[apk['packageName']] = apk
1624 if not os.path.exists(apk['per_app_icons']):
1625 logging.info('Adding new repo for only ' + apk['packageName'])
1626 os.makedirs(apk['per_app_icons'])
1628 apkpath = os.path.join(repodir, apk['apkName'])
1629 shutil.copy(apkpath, apk['per_app_repo'])
1630 apksigpath = apkpath + '.sig'
1631 if os.path.exists(apksigpath):
1632 shutil.copy(apksigpath, apk['per_app_repo'])
1633 apkascpath = apkpath + '.asc'
1634 if os.path.exists(apkascpath):
1635 shutil.copy(apkascpath, apk['per_app_repo'])
1638 def create_metadata_from_template(apk):
1639 '''create a new metadata file using internal or external template
1641 Generate warnings for apk's with no metadata (or create skeleton
1642 metadata files, if requested on the command line). Though the
1643 template file is YAML, this uses neither pyyaml nor ruamel.yaml
1644 since those impose things on the metadata file made from the
1645 template: field sort order, empty field value, formatting, etc.
1649 if os.path.exists('template.yml'):
1650 with open('template.yml') as f:
1652 if 'name' in apk and apk['name'] != '':
1653 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1654 r'\1 ' + apk['name'],
1656 flags=re.IGNORECASE | re.MULTILINE)
1658 logging.warning(apk['packageName'] + ' does not have a name! Using package name instead.')
1659 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1660 r'\1 ' + apk['packageName'],
1662 flags=re.IGNORECASE | re.MULTILINE)
1663 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1667 app['Categories'] = [os.path.basename(os.getcwd())]
1668 # include some blanks as part of the template
1669 app['AuthorName'] = ''
1672 app['IssueTracker'] = ''
1673 app['SourceCode'] = ''
1674 app['CurrentVersionCode'] = 2147483647 # Java's Integer.MAX_VALUE
1675 if 'name' in apk and apk['name'] != '':
1676 app['Name'] = apk['name']
1678 logging.warning(apk['packageName'] + ' does not have a name! Using package name instead.')
1679 app['Name'] = apk['packageName']
1680 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1681 yaml.dump(app, f, default_flow_style=False)
1682 logging.info("Generated skeleton metadata for " + apk['packageName'])
1691 global config, options
1693 # Parse command line...
1694 parser = ArgumentParser()
1695 common.setup_global_opts(parser)
1696 parser.add_argument("--create-key", action="store_true", default=False,
1697 help=_("Create a repo signing key in a keystore"))
1698 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1699 help=_("Create skeleton metadata files that are missing"))
1700 parser.add_argument("--delete-unknown", action="store_true", default=False,
1701 help=_("Delete APKs and/or OBBs without metadata from the repo"))
1702 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1703 help=_("Report on build data status"))
1704 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1705 help=_("Interactively ask about things that need updating."))
1706 parser.add_argument("-I", "--icons", action="store_true", default=False,
1707 help=_("Resize all the icons exceeding the max pixel size and exit"))
1708 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1709 help=_("Specify editor to use in interactive mode. Default ") +
1710 "is /etc/alternatives/editor")
1711 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1712 help=_("Update the wiki"))
1713 parser.add_argument("--pretty", action="store_true", default=False,
1714 help=_("Produce human-readable index.xml"))
1715 parser.add_argument("--clean", action="store_true", default=False,
1716 help=_("Clean update - don't uses caches, reprocess all APKs"))
1717 parser.add_argument("--nosign", action="store_true", default=False,
1718 help=_("When configured for signed indexes, create only unsigned indexes at this stage"))
1719 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1720 help=_("Use date from APK instead of current time for newly added APKs"))
1721 parser.add_argument("--rename-apks", action="store_true", default=False,
1722 help=_("Rename APK files that do not match package.name_123.apk"))
1723 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1724 help=_("Include APKs that are signed with disabled algorithms like MD5"))
1725 metadata.add_metadata_arguments(parser)
1726 options = parser.parse_args()
1727 metadata.warnings_action = options.W
1729 config = common.read_config(options)
1731 if not ('jarsigner' in config and 'keytool' in config):
1732 raise FDroidException('Java JDK not found! Install in standard location or set java_paths!')
1735 if config['archive_older'] != 0:
1736 repodirs.append('archive')
1737 if not os.path.exists('archive'):
1741 resize_all_icons(repodirs)
1744 if options.rename_apks:
1745 options.clean = True
1747 # check that icons exist now, rather than fail at the end of `fdroid update`
1748 for k in ['repo_icon', 'archive_icon']:
1750 if not os.path.exists(config[k]):
1751 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1754 # if the user asks to create a keystore, do it now, reusing whatever it can
1755 if options.create_key:
1756 if os.path.exists(config['keystore']):
1757 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1758 logging.critical("\t'" + config['keystore'] + "'")
1761 if 'repo_keyalias' not in config:
1762 config['repo_keyalias'] = socket.getfqdn()
1763 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1764 if 'keydname' not in config:
1765 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1766 common.write_to_config(config, 'keydname', config['keydname'])
1767 if 'keystore' not in config:
1768 config['keystore'] = common.default_config['keystore']
1769 common.write_to_config(config, 'keystore', config['keystore'])
1771 password = common.genpassword()
1772 if 'keystorepass' not in config:
1773 config['keystorepass'] = password
1774 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1775 if 'keypass' not in config:
1776 config['keypass'] = password
1777 common.write_to_config(config, 'keypass', config['keypass'])
1778 common.genkeystore(config)
1781 apps = metadata.read_metadata()
1783 # Generate a list of categories...
1785 for app in apps.values():
1786 categories.update(app.Categories)
1788 # Read known apks data (will be updated and written back when we've finished)
1789 knownapks = common.KnownApks()
1792 apkcache = get_cache()
1794 # Delete builds for disabled apps
1795 delete_disabled_builds(apps, apkcache, repodirs)
1797 # Scan all apks in the main repo
1798 apks, cachechanged = process_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1800 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1801 options.use_date_from_apk)
1802 cachechanged = cachechanged or fcachechanged
1805 if apk['packageName'] not in apps:
1806 if options.create_metadata:
1807 create_metadata_from_template(apk)
1808 apps = metadata.read_metadata()
1810 msg = apk['apkName'] + " (" + apk['packageName'] + ") has no metadata!"
1811 if options.delete_unknown:
1812 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkName'])
1813 rmf = os.path.join(repodirs[0], apk['apkName'])
1814 if not os.path.exists(rmf):
1815 logging.error("Could not find {0} to remove it".format(rmf))
1819 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1821 copy_triple_t_store_metadata(apps)
1822 insert_obbs(repodirs[0], apps, apks)
1823 insert_localized_app_metadata(apps)
1824 translate_per_build_anti_features(apps, apks)
1826 # Scan the archive repo for apks as well
1827 if len(repodirs) > 1:
1828 archapks, cc = process_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1834 # Apply information from latest apks to the application and update dates
1835 apply_info_from_latest_apk(apps, apks + archapks)
1837 # Sort the app list by name, then the web site doesn't have to by default.
1838 # (we had to wait until we'd scanned the apks to do this, because mostly the
1839 # name comes from there!)
1840 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1842 # APKs are placed into multiple repos based on the app package, providing
1843 # per-app subscription feeds for nightly builds and things like it
1844 if config['per_app_repos']:
1845 add_apks_to_per_app_repos(repodirs[0], apks)
1846 for appid, app in apps.items():
1847 repodir = os.path.join(appid, 'fdroid', 'repo')
1849 appdict[appid] = app
1850 if os.path.isdir(repodir):
1851 index.make(appdict, [appid], apks, repodir, False)
1853 logging.info('Skipping index generation for ' + appid)
1856 if len(repodirs) > 1:
1857 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1859 # Make the index for the main repo...
1860 index.make(apps, sortedids, apks, repodirs[0], False)
1861 make_categories_txt(repodirs[0], categories)
1863 # If there's an archive repo, make the index for it. We already scanned it
1865 if len(repodirs) > 1:
1866 index.make(apps, sortedids, archapks, repodirs[1], True)
1868 git_remote = config.get('binary_transparency_remote')
1869 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
1871 btlog.make_binary_transparency_log(repodirs)
1873 if config['update_stats']:
1874 # Update known apks info...
1875 knownapks.writeifchanged()
1877 # Generate latest apps data for widget
1878 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1880 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1882 appid = line.rstrip()
1883 data += appid + "\t"
1885 data += app.Name + "\t"
1886 if app.icon is not None:
1887 data += app.icon + "\t"
1888 data += app.License + "\n"
1889 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1893 write_cache(apkcache)
1895 # Update the wiki...
1897 update_wiki(apps, sortedids, apks + archapks)
1899 logging.info(_("Finished"))
1902 if __name__ == "__main__":