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 extension in ALLOWED_EXTENSIONS \
721 and (dirname in GRAPHIC_NAMES or dirname in SCREENSHOT_DIRS):
722 if segments[-2] == 'listing':
723 locale = segments[-3]
725 locale = segments[-2]
726 destdir = os.path.join('repo', packageName, locale, dirname)
727 os.makedirs(destdir, mode=0o755, exist_ok=True)
728 sourcefile = os.path.join(root, f)
729 destfile = os.path.join(destdir, os.path.basename(f))
730 logging.debug('copying ' + sourcefile + ' ' + destfile)
731 shutil.copy(sourcefile, destfile)
734 def insert_localized_app_metadata(apps):
735 """scans standard locations for graphics and localized text
737 Scans for localized description files, store graphics, and
738 screenshot PNG files in statically defined screenshots directory
739 and adds them to the app metadata. The screenshots and graphic
740 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
741 and must be in the following layout:
742 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
744 repo/packageName/locale/featureGraphic.png
745 repo/packageName/locale/phoneScreenshots/1.png
746 repo/packageName/locale/phoneScreenshots/2.png
748 The changelog files must be text files named with the versionCode
749 ending with ".txt" and must be in the following layout:
750 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
752 repo/packageName/locale/changelogs/12345.txt
754 This will scan the each app's source repo then the metadata/ dir
755 for these standard locations of changelog files. If it finds
756 them, they will be added to the dict of all packages, with the
757 versions in the metadata/ folder taking precendence over the what
758 is in the app's source repo.
760 Where "packageName" is the app's packageName and "locale" is the locale
761 of the graphics, e.g. what language they are in, using the IETF RFC5646
762 format (en-US, fr-CA, es-MX, etc).
764 This will also scan the app's git for a fastlane folder, and the
765 metadata/ folder and the apps' source repos for standard locations
766 of graphic and screenshot files. If it finds them, it will copy
767 them into the repo. The fastlane files follow this pattern:
768 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
772 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
773 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
774 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
776 for srcd in sorted(sourcedirs):
777 if not os.path.isdir(srcd):
779 for root, dirs, files in os.walk(srcd):
780 segments = root.split('/')
781 packageName = segments[1]
782 if packageName not in apps:
783 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
785 locale = segments[-1]
786 destdir = os.path.join('repo', packageName, locale)
788 if f in ('description.txt', 'full_description.txt'):
789 _set_localized_text_entry(apps[packageName], locale, 'description',
790 os.path.join(root, f))
792 elif f in ('summary.txt', 'short_description.txt'):
793 _set_localized_text_entry(apps[packageName], locale, 'summary',
794 os.path.join(root, f))
796 elif f in ('name.txt', 'title.txt'):
797 _set_localized_text_entry(apps[packageName], locale, 'name',
798 os.path.join(root, f))
800 elif f == 'video.txt':
801 _set_localized_text_entry(apps[packageName], locale, 'video',
802 os.path.join(root, f))
804 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
805 locale = segments[-2]
806 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
807 os.path.join(root, f))
810 base, extension = common.get_extension(f)
811 if locale == 'images':
812 locale = segments[-2]
813 destdir = os.path.join('repo', packageName, locale)
814 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
815 os.makedirs(destdir, mode=0o755, exist_ok=True)
816 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
817 shutil.copy(os.path.join(root, f), destdir)
819 if d in SCREENSHOT_DIRS:
820 if locale == 'images':
821 locale = segments[-2]
822 destdir = os.path.join('repo', packageName, locale)
823 for f in glob.glob(os.path.join(root, d, '*.*')):
824 _, extension = common.get_extension(f)
825 if extension in ALLOWED_EXTENSIONS:
826 screenshotdestdir = os.path.join(destdir, d)
827 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
828 logging.debug('copying ' + f + ' ' + screenshotdestdir)
829 shutil.copy(f, screenshotdestdir)
831 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
833 if not os.path.isdir(d):
835 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
836 if not os.path.isfile(f):
838 segments = f.split('/')
839 packageName = segments[1]
841 screenshotdir = segments[3]
842 filename = os.path.basename(f)
843 base, extension = common.get_extension(filename)
845 if packageName not in apps:
846 logging.warning('Found "%s" graphic without metadata for app "%s"!'
847 % (filename, packageName))
849 graphics = _get_localized_dict(apps[packageName], locale)
851 if extension not in ALLOWED_EXTENSIONS:
852 logging.warning('Only PNG and JPEG are supported for graphics, found: ' + f)
853 elif base in GRAPHIC_NAMES:
854 # there can only be zero or one of these per locale
855 graphics[base] = filename
856 elif screenshotdir in SCREENSHOT_DIRS:
857 # there can any number of these per locale
858 logging.debug('adding to ' + screenshotdir + ': ' + f)
859 if screenshotdir not in graphics:
860 graphics[screenshotdir] = []
861 graphics[screenshotdir].append(filename)
863 logging.warning('Unsupported graphics file found: ' + f)
866 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
867 """Scan a repo for all files with an extension except APK/OBB
869 :param apkcache: current cached info about all repo files
870 :param repodir: repo directory to scan
871 :param knownapks: list of all known files, as per metadata.read_metadata
872 :param use_date_from_file: use date from file (instead of current date)
873 for newly added files
878 repodir = repodir.encode('utf-8')
879 for name in os.listdir(repodir):
880 file_extension = common.get_file_extension(name)
881 if file_extension == 'apk' or file_extension == 'obb':
883 filename = os.path.join(repodir, name)
884 name_utf8 = name.decode('utf-8')
885 if filename.endswith(b'_src.tar.gz'):
886 logging.debug('skipping source tarball: ' + filename.decode('utf-8'))
888 if not common.is_repo_file(filename):
890 stat = os.stat(filename)
891 if stat.st_size == 0:
892 raise FDroidException(filename + ' is zero size!')
894 shasum = sha256sum(filename)
897 repo_file = apkcache[name]
898 # added time is cached as tuple but used here as datetime instance
899 if 'added' in repo_file:
900 a = repo_file['added']
901 if isinstance(a, datetime):
902 repo_file['added'] = a
904 repo_file['added'] = datetime(*a[:6])
905 if repo_file.get('hash') == shasum:
906 logging.debug("Reading " + name_utf8 + " from cache")
909 logging.debug("Ignoring stale cache data for " + name_utf8)
912 logging.debug(_("Processing {apkfilename}").format(apkfilename=name_utf8))
913 repo_file = collections.OrderedDict()
914 repo_file['name'] = os.path.splitext(name_utf8)[0]
915 # TODO rename apkname globally to something more generic
916 repo_file['apkName'] = name_utf8
917 repo_file['hash'] = shasum
918 repo_file['hashType'] = 'sha256'
919 repo_file['versionCode'] = 0
920 repo_file['versionName'] = shasum
921 # the static ID is the SHA256 unless it is set in the metadata
922 repo_file['packageName'] = shasum
924 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
926 repo_file['packageName'] = m.group(1)
927 repo_file['versionCode'] = int(m.group(2))
928 srcfilename = name + b'_src.tar.gz'
929 if os.path.exists(os.path.join(repodir, srcfilename)):
930 repo_file['srcname'] = srcfilename.decode('utf-8')
931 repo_file['size'] = stat.st_size
933 apkcache[name] = repo_file
936 if use_date_from_file:
937 timestamp = stat.st_ctime
938 default_date_param = time.gmtime(time.mktime(datetime.fromtimestamp(timestamp).timetuple()))
940 default_date_param = None
942 # Record in knownapks, getting the added date at the same time..
943 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
944 default_date=default_date_param)
946 repo_file['added'] = added
948 repo_files.append(repo_file)
950 return repo_files, cachechanged
953 def scan_apk(apk_file):
955 Scans an APK file and returns dictionary with metadata of the APK.
957 Attention: This does *not* verify that the APK signature is correct.
959 :param apk_file: The (ideally absolute) path to the APK file
960 :raises BuildException
961 :return A dict containing APK metadata
964 'hash': sha256sum(apk_file),
965 'hashType': 'sha256',
966 'uses-permission': [],
967 'uses-permission-sdk-23': [],
971 'antiFeatures': set(),
974 if SdkToolsPopen(['aapt', 'version'], output=False):
975 scan_apk_aapt(apk, apk_file)
977 scan_apk_androguard(apk, apk_file)
979 # Get the signature, or rather the signing key fingerprints
980 logging.debug('Getting signature of {0}'.format(os.path.basename(apk_file)))
981 apk['sig'] = getsig(apk_file)
983 raise BuildException("Failed to get apk signature")
984 apk['signer'] = common.apk_signer_fingerprint(os.path.join(os.getcwd(),
986 if not apk.get('signer'):
987 raise BuildException("Failed to get apk signing key fingerprint")
989 # Get size of the APK
990 apk['size'] = os.path.getsize(apk_file)
992 if 'minSdkVersion' not in apk:
993 logging.warning("No SDK version information found in {0}".format(apk_file))
994 apk['minSdkVersion'] = 1
996 # Check for known vulnerabilities
997 if has_known_vulnerability(apk_file):
998 apk['antiFeatures'].add('KnownVuln')
1003 def scan_apk_aapt(apk, apkfile):
1004 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1005 if p.returncode != 0:
1006 if options.delete_unknown:
1007 if os.path.exists(apkfile):
1008 logging.error("Failed to get apk information, deleting " + apkfile)
1011 logging.error("Could not find {0} to remove it".format(apkfile))
1013 logging.error("Failed to get apk information, skipping " + apkfile)
1014 raise BuildException("Invalid APK")
1015 for line in p.output.splitlines():
1016 if line.startswith("package:"):
1018 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
1019 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
1020 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
1021 except Exception as e:
1022 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
1023 elif line.startswith("application:"):
1024 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1025 # Keep path to non-dpi icon in case we need it
1026 match = re.match(APK_ICON_PAT_NODPI, line)
1028 apk['icons_src']['-1'] = match.group(1)
1029 elif line.startswith("launchable-activity:"):
1030 # Only use launchable-activity as fallback to application
1032 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
1033 if '-1' not in apk['icons_src']:
1034 match = re.match(APK_ICON_PAT_NODPI, line)
1036 apk['icons_src']['-1'] = match.group(1)
1037 elif line.startswith("application-icon-"):
1038 match = re.match(APK_ICON_PAT, line)
1040 density = match.group(1)
1041 path = match.group(2)
1042 apk['icons_src'][density] = path
1043 elif line.startswith("sdkVersion:"):
1044 m = re.match(APK_SDK_VERSION_PAT, line)
1046 logging.error(line.replace('sdkVersion:', '')
1047 + ' is not a valid minSdkVersion!')
1049 apk['minSdkVersion'] = m.group(1)
1050 # if target not set, default to min
1051 if 'targetSdkVersion' not in apk:
1052 apk['targetSdkVersion'] = m.group(1)
1053 elif line.startswith("targetSdkVersion:"):
1054 m = re.match(APK_SDK_VERSION_PAT, line)
1056 logging.error(line.replace('targetSdkVersion:', '')
1057 + ' is not a valid targetSdkVersion!')
1059 apk['targetSdkVersion'] = m.group(1)
1060 elif line.startswith("maxSdkVersion:"):
1061 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
1062 elif line.startswith("native-code:"):
1063 apk['nativecode'] = []
1064 for arch in line[13:].split(' '):
1065 apk['nativecode'].append(arch[1:-1])
1066 elif line.startswith('uses-permission:'):
1067 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1068 if perm_match['maxSdkVersion']:
1069 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1070 permission = UsesPermission(
1072 perm_match['maxSdkVersion']
1075 apk['uses-permission'].append(permission)
1076 elif line.startswith('uses-permission-sdk-23:'):
1077 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
1078 if perm_match['maxSdkVersion']:
1079 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
1080 permission_sdk_23 = UsesPermissionSdk23(
1082 perm_match['maxSdkVersion']
1085 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1087 elif line.startswith('uses-feature:'):
1088 feature = re.match(APK_FEATURE_PAT, line).group(1)
1089 # Filter out this, it's only added with the latest SDK tools and
1090 # causes problems for lots of apps.
1091 if feature != "android.hardware.screen.portrait" \
1092 and feature != "android.hardware.screen.landscape":
1093 if feature.startswith("android.feature."):
1094 feature = feature[16:]
1095 apk['features'].add(feature)
1098 def scan_apk_androguard(apk, apkfile):
1100 from androguard.core.bytecodes.apk import APK
1101 apkobject = APK(apkfile)
1102 if apkobject.is_valid_APK():
1103 arsc = apkobject.get_android_resources()
1105 if options.delete_unknown:
1106 if os.path.exists(apkfile):
1107 logging.error("Failed to get apk information, deleting " + apkfile)
1110 logging.error("Could not find {0} to remove it".format(apkfile))
1112 logging.error("Failed to get apk information, skipping " + apkfile)
1113 raise BuildException("Invaild APK")
1115 raise FDroidException("androguard library is not installed and aapt not present")
1116 except FileNotFoundError:
1117 logging.error("Could not open apk file for analysis")
1118 raise BuildException("Invalid APK")
1120 apk['packageName'] = apkobject.get_package()
1121 apk['versionCode'] = int(apkobject.get_androidversion_code())
1122 apk['versionName'] = apkobject.get_androidversion_name()
1123 if apk['versionName'][0] == "@":
1124 version_id = int(apk['versionName'].replace("@", "0x"), 16)
1125 version_id = arsc.get_id(apk['packageName'], version_id)[1]
1126 apk['versionName'] = arsc.get_string(apk['packageName'], version_id)[1]
1127 apk['name'] = apkobject.get_app_name()
1129 if apkobject.get_max_sdk_version() is not None:
1130 apk['maxSdkVersion'] = apkobject.get_max_sdk_version()
1131 apk['minSdkVersion'] = apkobject.get_min_sdk_version()
1132 apk['targetSdkVersion'] = apkobject.get_target_sdk_version()
1134 icon_id = int(apkobject.get_element("application", "icon").replace("@", "0x"), 16)
1135 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1137 density_re = re.compile("^res/(.*)/" + icon_name + ".*$")
1139 for file in apkobject.get_files():
1140 d_re = density_re.match(file)
1142 folder = d_re.group(1).split('-')
1144 resolution = folder[1]
1147 density = screen_resolutions[resolution]
1148 apk['icons_src'][density] = d_re.group(0)
1150 if apk['icons_src'].get('-1') is None:
1151 apk['icons_src']['-1'] = apk['icons_src']['160']
1153 arch_re = re.compile("^lib/(.*)/.*$")
1154 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1156 apk['nativecode'] = []
1157 apk['nativecode'].extend(sorted(list(arch)))
1159 xml = apkobject.get_android_manifest_xml()
1161 for item in xml.getElementsByTagName('uses-permission'):
1162 name = str(item.getAttribute("android:name"))
1163 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1164 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1165 permission = UsesPermission(
1169 apk['uses-permission'].append(permission)
1171 for item in xml.getElementsByTagName('uses-permission-sdk-23'):
1172 name = str(item.getAttribute("android:name"))
1173 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1174 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1175 permission_sdk_23 = UsesPermissionSdk23(
1179 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1181 for item in xml.getElementsByTagName('uses-feature'):
1182 feature = str(item.getAttribute("android:name"))
1183 if feature != "android.hardware.screen.portrait" \
1184 and feature != "android.hardware.screen.landscape":
1185 if feature.startswith("android.feature."):
1186 feature = feature[16:]
1187 apk['features'].append(feature)
1190 def process_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1191 allow_disabled_algorithms=False, archive_bad_sig=False):
1192 """Processes the apk with the given filename in the given repo directory.
1194 This also extracts the icons.
1196 :param apkcache: current apk cache information
1197 :param apkfilename: the filename of the apk to scan
1198 :param repodir: repo directory to scan
1199 :param knownapks: known apks info
1200 :param use_date_from_apk: use date from APK (instead of current date)
1201 for newly added APKs
1202 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1203 disabled algorithms in the signature (e.g. MD5)
1204 :param archive_bad_sig: move APKs with a bad signature to the archive
1205 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1206 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1210 apkfile = os.path.join(repodir, apkfilename)
1212 cachechanged = False
1214 if apkfilename in apkcache:
1215 apk = apkcache[apkfilename]
1216 if apk.get('hash') == sha256sum(apkfile):
1217 logging.debug("Reading " + apkfilename + " from cache")
1220 logging.debug("Ignoring stale cache data for " + apkfilename)
1223 logging.debug(_("Processing {apkfilename}").format(apkfilename=apkfilename))
1226 apk = scan_apk(apkfile)
1227 except BuildException:
1228 logging.warning(_("Skipping '{apkfilename}' with invalid signature!")
1229 .format(apkfilename=apkfilename))
1230 return True, None, False
1232 # Check for debuggable apks...
1233 if common.isApkAndDebuggable(apkfile):
1234 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1236 if options.rename_apks:
1237 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1238 std_short_name = os.path.join(repodir, n)
1239 if apkfile != std_short_name:
1240 if os.path.exists(std_short_name):
1241 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1242 if apkfile != std_long_name:
1243 if os.path.exists(std_long_name):
1244 dupdir = os.path.join('duplicates', repodir)
1245 if not os.path.isdir(dupdir):
1246 os.makedirs(dupdir, exist_ok=True)
1247 dupfile = os.path.join('duplicates', std_long_name)
1248 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1249 os.rename(apkfile, dupfile)
1250 return True, None, False
1252 os.rename(apkfile, std_long_name)
1253 apkfile = std_long_name
1255 os.rename(apkfile, std_short_name)
1256 apkfile = std_short_name
1257 apkfilename = apkfile[len(repodir) + 1:]
1259 apk['apkName'] = apkfilename
1260 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1261 if os.path.exists(os.path.join(repodir, srcfilename)):
1262 apk['srcname'] = srcfilename
1264 # verify the jar signature is correct, allow deprecated
1265 # algorithms only if the APK is in the archive.
1267 if not common.verify_apk_signature(apkfile):
1268 if repodir == 'archive' or allow_disabled_algorithms:
1269 if common.verify_old_apk_signature(apkfile):
1270 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1278 logging.warning('Archiving "' + apkfilename + '" with invalid signature!')
1279 move_apk_between_sections(repodir, 'archive', apk)
1281 logging.warning('Skipping "' + apkfilename + '" with invalid signature!')
1282 return True, None, False
1284 apkzip = zipfile.ZipFile(apkfile, 'r')
1286 # if an APK has files newer than the system time, suggest updating
1287 # the system clock. This is useful for offline systems, used for
1288 # signing, which do not have another source of clock sync info. It
1289 # has to be more than 24 hours newer because ZIP/APK files do not
1290 # store timezone info
1291 manifest = apkzip.getinfo('AndroidManifest.xml')
1292 if manifest.date_time[1] == 0: # month can't be zero
1293 logging.debug('AndroidManifest.xml has no date')
1295 dt_obj = datetime(*manifest.date_time)
1296 checkdt = dt_obj - timedelta(1)
1297 if datetime.today() < checkdt:
1298 logging.warning('System clock is older than manifest in: '
1300 + '\nSet clock to that time using:\n'
1301 + 'sudo date -s "' + str(dt_obj) + '"')
1303 # extract icons from APK zip file
1304 iconfilename = "%s.%s.png" % (apk['packageName'], apk['versionCode'])
1306 empty_densities = extract_apk_icons(iconfilename, apk, apkzip, repodir)
1308 apkzip.close() # ensure that APK zip file gets closed
1310 # resize existing icons for densities missing in the APK
1311 fill_missing_icon_densities(empty_densities, iconfilename, apk, repodir)
1313 if use_date_from_apk and manifest.date_time[1] != 0:
1314 default_date_param = datetime(*manifest.date_time)
1316 default_date_param = None
1318 # Record in known apks, getting the added date at the same time..
1319 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1320 default_date=default_date_param)
1322 apk['added'] = added
1324 apkcache[apkfilename] = apk
1327 return False, apk, cachechanged
1330 def process_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1331 """Processes the apks in the given repo directory.
1333 This also extracts the icons.
1335 :param apkcache: current apk cache information
1336 :param repodir: repo directory to scan
1337 :param knownapks: known apks info
1338 :param use_date_from_apk: use date from APK (instead of current date)
1339 for newly added APKs
1340 :returns: (apks, cachechanged) where apks is a list of apk information,
1341 and cachechanged is True if the apkcache got changed.
1344 cachechanged = False
1346 for icon_dir in get_all_icon_dirs(repodir):
1347 if os.path.exists(icon_dir):
1349 shutil.rmtree(icon_dir)
1350 os.makedirs(icon_dir)
1352 os.makedirs(icon_dir)
1355 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1356 apkfilename = apkfile[len(repodir) + 1:]
1357 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1358 (skip, apk, cachethis) = process_apk(apkcache, apkfilename, repodir, knownapks,
1359 use_date_from_apk, ada, True)
1363 cachechanged = cachechanged or cachethis
1365 return apks, cachechanged
1368 def extract_apk_icons(icon_filename, apk, apkzip, repo_dir):
1370 Extracts icons from the given APK zip in various densities,
1371 saves them into given repo directory
1372 and stores their names in the APK metadata dictionary.
1374 :param icon_filename: A string representing the icon's file name
1375 :param apk: A populated dictionary containing APK metadata.
1376 Needs to have 'icons_src' key
1377 :param apkzip: An opened zipfile.ZipFile of the APK file
1378 :param repo_dir: The directory of the APK's repository
1379 :return: A list of icon densities that are missing
1381 empty_densities = []
1382 for density in screen_densities:
1383 if density not in apk['icons_src']:
1384 empty_densities.append(density)
1386 icon_src = apk['icons_src'][density]
1387 icon_dir = get_icon_dir(repo_dir, density)
1388 icon_dest = os.path.join(icon_dir, icon_filename)
1390 # Extract the icon files per density
1391 if icon_src.endswith('.xml'):
1392 png = os.path.basename(icon_src)[:-4] + '.png'
1393 for f in apkzip.namelist():
1395 m = re.match(r'res/(drawable|mipmap)-(x*[hlm]dpi).*/', f)
1396 if m and screen_resolutions[m.group(2)] == density:
1398 if icon_src.endswith('.xml'):
1399 empty_densities.append(density)
1402 with open(icon_dest, 'wb') as f:
1403 f.write(get_icon_bytes(apkzip, icon_src))
1404 apk['icons'][density] = icon_filename
1405 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1406 logging.warning("Error retrieving icon file: %s %s", icon_dest, e)
1407 del apk['icons_src'][density]
1408 empty_densities.append(density)
1410 if '-1' in apk['icons_src']:
1411 icon_src = apk['icons_src']['-1']
1412 icon_path = os.path.join(get_icon_dir(repo_dir, '0'), icon_filename)
1413 with open(icon_path, 'wb') as f:
1414 f.write(get_icon_bytes(apkzip, icon_src))
1416 im = Image.open(icon_path)
1417 dpi = px_to_dpi(im.size[0])
1418 for density in screen_densities:
1419 if density in apk['icons']:
1421 if density == screen_densities[-1] or dpi >= int(density):
1422 apk['icons'][density] = icon_filename
1423 shutil.move(icon_path,
1424 os.path.join(get_icon_dir(repo_dir, density), icon_filename))
1425 empty_densities.remove(density)
1427 except Exception as e:
1428 logging.warning("Failed reading {0} - {1}".format(icon_path, e))
1431 apk['icon'] = icon_filename
1433 return empty_densities
1436 def fill_missing_icon_densities(empty_densities, icon_filename, apk, repo_dir):
1438 Resize existing icons for densities missing in the APK to ensure all densities are available
1440 :param empty_densities: A list of icon densities that are missing
1441 :param icon_filename: A string representing the icon's file name
1442 :param apk: A populated dictionary containing APK metadata. Needs to have 'icons' key
1443 :param repo_dir: The directory of the APK's repository
1445 # First try resizing down to not lose quality
1447 for density in screen_densities:
1448 if density not in empty_densities:
1449 last_density = density
1451 if last_density is None:
1453 logging.debug("Density %s not available, resizing down from %s", density, last_density)
1455 last_icon_path = os.path.join(get_icon_dir(repo_dir, last_density), icon_filename)
1456 icon_path = os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1459 fp = open(last_icon_path, 'rb')
1462 size = dpi_to_px(density)
1464 im.thumbnail((size, size), Image.ANTIALIAS)
1465 im.save(icon_path, "PNG")
1466 empty_densities.remove(density)
1467 except Exception as e:
1468 logging.warning("Invalid image file at %s: %s", last_icon_path, e)
1473 # Then just copy from the highest resolution available
1475 for density in reversed(screen_densities):
1476 if density not in empty_densities:
1477 last_density = density
1480 if last_density is None:
1484 os.path.join(get_icon_dir(repo_dir, last_density), icon_filename),
1485 os.path.join(get_icon_dir(repo_dir, density), icon_filename)
1487 empty_densities.remove(density)
1489 for density in screen_densities:
1490 icon_dir = get_icon_dir(repo_dir, density)
1491 icon_dest = os.path.join(icon_dir, icon_filename)
1492 resize_icon(icon_dest, density)
1494 # Copy from icons-mdpi to icons since mdpi is the baseline density
1495 baseline = os.path.join(get_icon_dir(repo_dir, '160'), icon_filename)
1496 if os.path.isfile(baseline):
1497 apk['icons']['0'] = icon_filename
1498 shutil.copyfile(baseline, os.path.join(get_icon_dir(repo_dir, '0'), icon_filename))
1501 def apply_info_from_latest_apk(apps, apks):
1503 Some information from the apks needs to be applied up to the application level.
1504 When doing this, we use the info from the most recent version's apk.
1505 We deal with figuring out when the app was added and last updated at the same time.
1507 for appid, app in apps.items():
1508 bestver = UNSET_VERSION_CODE
1510 if apk['packageName'] == appid:
1511 if apk['versionCode'] > bestver:
1512 bestver = apk['versionCode']
1516 if not app.added or apk['added'] < app.added:
1517 app.added = apk['added']
1518 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1519 app.lastUpdated = apk['added']
1522 logging.debug("Don't know when " + appid + " was added")
1523 if not app.lastUpdated:
1524 logging.debug("Don't know when " + appid + " was last updated")
1526 if bestver == UNSET_VERSION_CODE:
1528 if app.Name is None:
1529 app.Name = app.AutoName or appid
1531 logging.debug("Application " + appid + " has no packages")
1533 if app.Name is None:
1534 app.Name = bestapk['name']
1535 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1536 if app.CurrentVersionCode is None:
1537 app.CurrentVersionCode = str(bestver)
1540 def make_categories_txt(repodir, categories):
1541 '''Write a category list in the repo to allow quick access'''
1543 for cat in sorted(categories):
1544 catdata += cat + '\n'
1545 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1549 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1551 def filter_apk_list_sorted(apk_list):
1553 for apk in apk_list:
1554 if apk['packageName'] == appid:
1557 # Sort the apk list by version code. First is highest/newest.
1558 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1560 for appid, app in apps.items():
1562 if app.ArchivePolicy:
1563 keepversions = int(app.ArchivePolicy[:-9])
1565 keepversions = defaultkeepversions
1567 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1568 .format(appid, len(apks), keepversions, len(archapks)))
1570 current_app_apks = filter_apk_list_sorted(apks)
1571 if len(current_app_apks) > keepversions:
1572 # Move back the ones we don't want.
1573 for apk in current_app_apks[keepversions:]:
1574 move_apk_between_sections(repodir, archivedir, apk)
1575 archapks.append(apk)
1578 current_app_archapks = filter_apk_list_sorted(archapks)
1579 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1581 # Move forward the ones we want again, except DisableAlgorithm
1582 for apk in current_app_archapks:
1583 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1584 move_apk_between_sections(archivedir, repodir, apk)
1585 archapks.remove(apk)
1588 if kept == keepversions:
1592 def move_apk_between_sections(from_dir, to_dir, apk):
1593 """move an APK from repo to archive or vice versa"""
1595 def _move_file(from_dir, to_dir, filename, ignore_missing):
1596 from_path = os.path.join(from_dir, filename)
1597 if ignore_missing and not os.path.exists(from_path):
1599 to_path = os.path.join(to_dir, filename)
1600 if not os.path.exists(to_dir):
1602 shutil.move(from_path, to_path)
1604 if from_dir == to_dir:
1607 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1608 _move_file(from_dir, to_dir, apk['apkName'], False)
1609 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1610 for density in all_screen_densities:
1611 from_icon_dir = get_icon_dir(from_dir, density)
1612 to_icon_dir = get_icon_dir(to_dir, density)
1613 if density not in apk['icons']:
1615 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1616 if 'srcname' in apk:
1617 _move_file(from_dir, to_dir, apk['srcname'], False)
1620 def add_apks_to_per_app_repos(repodir, apks):
1621 apks_per_app = dict()
1623 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1624 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1625 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1626 apks_per_app[apk['packageName']] = apk
1628 if not os.path.exists(apk['per_app_icons']):
1629 logging.info('Adding new repo for only ' + apk['packageName'])
1630 os.makedirs(apk['per_app_icons'])
1632 apkpath = os.path.join(repodir, apk['apkName'])
1633 shutil.copy(apkpath, apk['per_app_repo'])
1634 apksigpath = apkpath + '.sig'
1635 if os.path.exists(apksigpath):
1636 shutil.copy(apksigpath, apk['per_app_repo'])
1637 apkascpath = apkpath + '.asc'
1638 if os.path.exists(apkascpath):
1639 shutil.copy(apkascpath, apk['per_app_repo'])
1642 def create_metadata_from_template(apk):
1643 '''create a new metadata file using internal or external template
1645 Generate warnings for apk's with no metadata (or create skeleton
1646 metadata files, if requested on the command line). Though the
1647 template file is YAML, this uses neither pyyaml nor ruamel.yaml
1648 since those impose things on the metadata file made from the
1649 template: field sort order, empty field value, formatting, etc.
1653 if os.path.exists('template.yml'):
1654 with open('template.yml') as f:
1656 if 'name' in apk and apk['name'] != '':
1657 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1658 r'\1 ' + apk['name'],
1660 flags=re.IGNORECASE | re.MULTILINE)
1662 logging.warning(apk['packageName'] + ' does not have a name! Using package name instead.')
1663 metatxt = re.sub(r'^(((Auto)?Name|Summary):).*$',
1664 r'\1 ' + apk['packageName'],
1666 flags=re.IGNORECASE | re.MULTILINE)
1667 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1671 app['Categories'] = [os.path.basename(os.getcwd())]
1672 # include some blanks as part of the template
1673 app['AuthorName'] = ''
1676 app['IssueTracker'] = ''
1677 app['SourceCode'] = ''
1678 app['CurrentVersionCode'] = 2147483647 # Java's Integer.MAX_VALUE
1679 if 'name' in apk and apk['name'] != '':
1680 app['Name'] = apk['name']
1682 logging.warning(apk['packageName'] + ' does not have a name! Using package name instead.')
1683 app['Name'] = apk['packageName']
1684 with open(os.path.join('metadata', apk['packageName'] + '.yml'), 'w') as f:
1685 yaml.dump(app, f, default_flow_style=False)
1686 logging.info("Generated skeleton metadata for " + apk['packageName'])
1695 global config, options
1697 # Parse command line...
1698 parser = ArgumentParser()
1699 common.setup_global_opts(parser)
1700 parser.add_argument("--create-key", action="store_true", default=False,
1701 help=_("Create a repo signing key in a keystore"))
1702 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1703 help=_("Create skeleton metadata files that are missing"))
1704 parser.add_argument("--delete-unknown", action="store_true", default=False,
1705 help=_("Delete APKs and/or OBBs without metadata from the repo"))
1706 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1707 help=_("Report on build data status"))
1708 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1709 help=_("Interactively ask about things that need updating."))
1710 parser.add_argument("-I", "--icons", action="store_true", default=False,
1711 help=_("Resize all the icons exceeding the max pixel size and exit"))
1712 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1713 help=_("Specify editor to use in interactive mode. Default ") +
1714 "is /etc/alternatives/editor")
1715 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1716 help=_("Update the wiki"))
1717 parser.add_argument("--pretty", action="store_true", default=False,
1718 help=_("Produce human-readable index.xml"))
1719 parser.add_argument("--clean", action="store_true", default=False,
1720 help=_("Clean update - don't uses caches, reprocess all APKs"))
1721 parser.add_argument("--nosign", action="store_true", default=False,
1722 help=_("When configured for signed indexes, create only unsigned indexes at this stage"))
1723 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1724 help=_("Use date from APK instead of current time for newly added APKs"))
1725 parser.add_argument("--rename-apks", action="store_true", default=False,
1726 help=_("Rename APK files that do not match package.name_123.apk"))
1727 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1728 help=_("Include APKs that are signed with disabled algorithms like MD5"))
1729 metadata.add_metadata_arguments(parser)
1730 options = parser.parse_args()
1731 metadata.warnings_action = options.W
1733 config = common.read_config(options)
1735 if not ('jarsigner' in config and 'keytool' in config):
1736 raise FDroidException('Java JDK not found! Install in standard location or set java_paths!')
1739 if config['archive_older'] != 0:
1740 repodirs.append('archive')
1741 if not os.path.exists('archive'):
1745 resize_all_icons(repodirs)
1748 if options.rename_apks:
1749 options.clean = True
1751 # check that icons exist now, rather than fail at the end of `fdroid update`
1752 for k in ['repo_icon', 'archive_icon']:
1754 if not os.path.exists(config[k]):
1755 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1758 # if the user asks to create a keystore, do it now, reusing whatever it can
1759 if options.create_key:
1760 if os.path.exists(config['keystore']):
1761 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1762 logging.critical("\t'" + config['keystore'] + "'")
1765 if 'repo_keyalias' not in config:
1766 config['repo_keyalias'] = socket.getfqdn()
1767 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1768 if 'keydname' not in config:
1769 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1770 common.write_to_config(config, 'keydname', config['keydname'])
1771 if 'keystore' not in config:
1772 config['keystore'] = common.default_config['keystore']
1773 common.write_to_config(config, 'keystore', config['keystore'])
1775 password = common.genpassword()
1776 if 'keystorepass' not in config:
1777 config['keystorepass'] = password
1778 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1779 if 'keypass' not in config:
1780 config['keypass'] = password
1781 common.write_to_config(config, 'keypass', config['keypass'])
1782 common.genkeystore(config)
1785 apps = metadata.read_metadata()
1787 # Generate a list of categories...
1789 for app in apps.values():
1790 categories.update(app.Categories)
1792 # Read known apks data (will be updated and written back when we've finished)
1793 knownapks = common.KnownApks()
1796 apkcache = get_cache()
1798 # Delete builds for disabled apps
1799 delete_disabled_builds(apps, apkcache, repodirs)
1801 # Scan all apks in the main repo
1802 apks, cachechanged = process_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1804 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1805 options.use_date_from_apk)
1806 cachechanged = cachechanged or fcachechanged
1809 if apk['packageName'] not in apps:
1810 if options.create_metadata:
1811 create_metadata_from_template(apk)
1812 apps = metadata.read_metadata()
1814 msg = apk['apkName'] + " (" + apk['packageName'] + ") has no metadata!"
1815 if options.delete_unknown:
1816 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkName'])
1817 rmf = os.path.join(repodirs[0], apk['apkName'])
1818 if not os.path.exists(rmf):
1819 logging.error("Could not find {0} to remove it".format(rmf))
1823 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1825 copy_triple_t_store_metadata(apps)
1826 insert_obbs(repodirs[0], apps, apks)
1827 insert_localized_app_metadata(apps)
1828 translate_per_build_anti_features(apps, apks)
1830 # Scan the archive repo for apks as well
1831 if len(repodirs) > 1:
1832 archapks, cc = process_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1838 # Apply information from latest apks to the application and update dates
1839 apply_info_from_latest_apk(apps, apks + archapks)
1841 # Sort the app list by name, then the web site doesn't have to by default.
1842 # (we had to wait until we'd scanned the apks to do this, because mostly the
1843 # name comes from there!)
1844 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1846 # APKs are placed into multiple repos based on the app package, providing
1847 # per-app subscription feeds for nightly builds and things like it
1848 if config['per_app_repos']:
1849 add_apks_to_per_app_repos(repodirs[0], apks)
1850 for appid, app in apps.items():
1851 repodir = os.path.join(appid, 'fdroid', 'repo')
1853 appdict[appid] = app
1854 if os.path.isdir(repodir):
1855 index.make(appdict, [appid], apks, repodir, False)
1857 logging.info('Skipping index generation for ' + appid)
1860 if len(repodirs) > 1:
1861 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1863 # Make the index for the main repo...
1864 index.make(apps, sortedids, apks, repodirs[0], False)
1865 make_categories_txt(repodirs[0], categories)
1867 # If there's an archive repo, make the index for it. We already scanned it
1869 if len(repodirs) > 1:
1870 index.make(apps, sortedids, archapks, repodirs[1], True)
1872 git_remote = config.get('binary_transparency_remote')
1873 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
1875 btlog.make_binary_transparency_log(repodirs)
1877 if config['update_stats']:
1878 # Update known apks info...
1879 knownapks.writeifchanged()
1881 # Generate latest apps data for widget
1882 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1884 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1886 appid = line.rstrip()
1887 data += appid + "\t"
1889 data += app.Name + "\t"
1890 if app.icon is not None:
1891 data += app.icon + "\t"
1892 data += app.License + "\n"
1893 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1897 write_cache(apkcache)
1899 # Update the wiki...
1901 update_wiki(apps, sortedids, apks + archapks)
1903 logging.info(_("Finished"))
1906 if __name__ == "__main__":