3 # update.py - part of the FDroid server tools
4 # Copyright (C) 2016, Blue Jay Wireless
5 # Copyright (C) 2014-2016, Hans-Christoph Steiner <hans@eds.org>
6 # Copyright (C) 2010-2015, Ciaran Gultnieks <ciaran@ciarang.com>
7 # Copyright (C) 2013-2014, Daniel Martà <mvdan@mvdan.cc>
9 # This program is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU Affero General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU Affero General Public License for more details.
19 # You should have received a copy of the GNU Affero General Public License
20 # along with this program. If not, see <http://www.gnu.org/licenses/>.
31 from datetime import datetime, timedelta
32 from argparse import ArgumentParser
35 from binascii import hexlify
42 from . import metadata
43 from .common import SdkToolsPopen
44 from .exception import BuildException, FDroidException
48 # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
49 UNSET_VERSION_CODE = -0x100000000
51 APK_NAME_PAT = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
52 APK_VERCODE_PAT = re.compile(".*versionCode='([0-9]*)'.*")
53 APK_VERNAME_PAT = re.compile(".*versionName='([^']*)'.*")
54 APK_LABEL_PAT = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
55 APK_ICON_PAT = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
56 APK_ICON_PAT_NODPI = re.compile(".*icon='([^']+?)'.*")
57 APK_SDK_VERSION_PAT = re.compile(".*'([0-9]*)'.*")
58 APK_PERMISSION_PAT = \
59 re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
60 APK_FEATURE_PAT = re.compile(".*name='([^']*)'.*")
62 screen_densities = ['640', '480', '320', '240', '160', '120']
63 screen_resolutions = {
75 all_screen_densities = ['0'] + screen_densities
77 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
78 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
80 ALLOWED_EXTENSIONS = ('png', 'jpg', 'jpeg')
81 GRAPHIC_NAMES = ('featureGraphic', 'icon', 'promoGraphic', 'tvBanner')
82 SCREENSHOT_DIRS = ('phoneScreenshots', 'sevenInchScreenshots',
83 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots')
86 def dpi_to_px(density):
87 return (int(density) * 48) / 160
91 return (int(px) * 160) / 48
94 def get_icon_dir(repodir, density):
96 return os.path.join(repodir, "icons")
97 return os.path.join(repodir, "icons-%s" % density)
100 def get_icon_dirs(repodir):
101 for density in screen_densities:
102 yield get_icon_dir(repodir, density)
105 def get_all_icon_dirs(repodir):
106 for density in all_screen_densities:
107 yield get_icon_dir(repodir, density)
110 def update_wiki(apps, sortedids, apks):
113 :param apps: fully populated list of all applications
114 :param apks: all apks, except...
116 logging.info("Updating wiki")
118 wikiredircat = 'App Redirects'
120 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
121 path=config['wiki_path'])
122 site.login(config['wiki_user'], config['wiki_password'])
124 generated_redirects = {}
126 for appid in sortedids:
127 app = metadata.App(apps[appid])
131 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
133 for af in app.AntiFeatures:
134 wikidata += '{{AntiFeature|' + af + '}}\n'
139 wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|changelog=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|license=%s|root=%s|author=%s|email=%s}}\n' % (
142 app.added.strftime('%Y-%m-%d') if app.added else '',
143 app.lastUpdated.strftime('%Y-%m-%d') if app.lastUpdated else '',
158 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
160 wikidata += app.Summary
161 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
163 wikidata += "=Description=\n"
164 wikidata += metadata.description_wiki(app.Description) + "\n"
166 wikidata += "=Maintainer Notes=\n"
167 if app.MaintainerNotes:
168 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
169 wikidata += "\nMetadata: [https://gitlab.com/fdroid/fdroiddata/blob/master/metadata/{0}.txt current] [https://gitlab.com/fdroid/fdroiddata/commits/master/metadata/{0}.txt history]\n".format(appid)
171 # Get a list of all packages for this application...
173 gotcurrentver = False
177 if apk['packageName'] == appid:
178 if str(apk['versionCode']) == app.CurrentVersionCode:
181 # Include ones we can't build, as a special case...
182 for build in app.builds:
184 if build.versionCode == app.CurrentVersionCode:
186 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
187 apklist.append({'versionCode': int(build.versionCode),
188 'versionName': build.versionName,
189 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
194 if apk['versionCode'] == int(build.versionCode):
199 apklist.append({'versionCode': int(build.versionCode),
200 'versionName': build.versionName,
201 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.versionCode),
203 if app.CurrentVersionCode == '0':
205 # Sort with most recent first...
206 apklist = sorted(apklist, key=lambda apk: apk['versionCode'], reverse=True)
208 wikidata += "=Versions=\n"
209 if len(apklist) == 0:
210 wikidata += "We currently have no versions of this app available."
211 elif not gotcurrentver:
212 wikidata += "We don't have the current version of this app."
214 wikidata += "We have the current version of this app."
215 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
216 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
217 if len(app.NoSourceSince) > 0:
218 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
219 if len(app.CurrentVersion) > 0:
220 wikidata += "The current (recommended) version is " + app.CurrentVersion
221 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
224 wikidata += "==" + apk['versionName'] + "==\n"
226 if 'buildproblem' in apk:
227 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
230 wikidata += "This version is built and signed by "
232 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
234 wikidata += "the original developer.\n\n"
235 wikidata += "Version code: " + str(apk['versionCode']) + '\n'
237 wikidata += '\n[[Category:' + wikicat + ']]\n'
238 if len(app.NoSourceSince) > 0:
239 wikidata += '\n[[Category:Apps missing source code]]\n'
240 if validapks == 0 and not app.Disabled:
241 wikidata += '\n[[Category:Apps with no packages]]\n'
242 if cantupdate and not app.Disabled:
243 wikidata += "\n[[Category:Apps we cannot update]]\n"
244 if buildfails and not app.Disabled:
245 wikidata += "\n[[Category:Apps with failing builds]]\n"
246 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
247 wikidata += '\n[[Category:Apps to Update]]\n'
249 wikidata += '\n[[Category:Apps that are disabled]]\n'
250 if app.UpdateCheckMode == 'None' and not app.Disabled:
251 wikidata += '\n[[Category:Apps with no update check]]\n'
252 for appcat in app.Categories:
253 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
255 # We can't have underscores in the page name, even if they're in
256 # the package ID, because MediaWiki messes with them...
257 pagename = appid.replace('_', ' ')
259 # Drop a trailing newline, because mediawiki is going to drop it anyway
260 # and it we don't we'll think the page has changed when it hasn't...
261 if wikidata.endswith('\n'):
262 wikidata = wikidata[:-1]
264 generated_pages[pagename] = wikidata
266 # Make a redirect from the name to the ID too, unless there's
267 # already an existing page with the name and it isn't a redirect.
269 apppagename = app.Name.replace('_', ' ')
270 apppagename = apppagename.replace('{', '')
271 apppagename = apppagename.replace('}', ' ')
272 apppagename = apppagename.replace(':', ' ')
273 apppagename = apppagename.replace('[', ' ')
274 apppagename = apppagename.replace(']', ' ')
275 # Drop double spaces caused mostly by replacing ':' above
276 apppagename = apppagename.replace(' ', ' ')
277 for expagename in site.allpages(prefix=apppagename,
278 filterredir='nonredirects',
280 if expagename == apppagename:
282 # Another reason not to make the redirect page is if the app name
283 # is the same as it's ID, because that will overwrite the real page
284 # with an redirect to itself! (Although it seems like an odd
285 # scenario this happens a lot, e.g. where there is metadata but no
286 # builds or binaries to extract a name from.
287 if apppagename == pagename:
290 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
292 for tcat, genp in [(wikicat, generated_pages),
293 (wikiredircat, generated_redirects)]:
294 catpages = site.Pages['Category:' + tcat]
296 for page in catpages:
297 existingpages.append(page.name)
298 if page.name in genp:
299 pagetxt = page.edit()
300 if pagetxt != genp[page.name]:
301 logging.debug("Updating modified page " + page.name)
302 page.save(genp[page.name], summary='Auto-updated')
304 logging.debug("Page " + page.name + " is unchanged")
306 logging.warn("Deleting page " + page.name)
307 page.delete('No longer published')
308 for pagename, text in genp.items():
309 logging.debug("Checking " + pagename)
310 if pagename not in existingpages:
311 logging.debug("Creating page " + pagename)
313 newpage = site.Pages[pagename]
314 newpage.save(text, summary='Auto-created')
315 except Exception as e:
316 logging.error("...FAILED to create page '{0}': {1}".format(pagename, e))
318 # Purge server cache to ensure counts are up to date
319 site.pages['Repository Maintenance'].purge()
322 def delete_disabled_builds(apps, apkcache, repodirs):
323 """Delete disabled build outputs.
325 :param apps: list of all applications, as per metadata.read_metadata
326 :param apkcache: current apk cache information
327 :param repodirs: the repo directories to process
329 for appid, app in apps.items():
330 for build in app['builds']:
331 if not build.disable:
333 apkfilename = common.get_release_filename(app, build)
334 iconfilename = "%s.%s.png" % (
337 for repodir in repodirs:
339 os.path.join(repodir, apkfilename),
340 os.path.join(repodir, apkfilename + '.asc'),
341 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
343 for density in all_screen_densities:
344 repo_dir = get_icon_dir(repodir, density)
345 files.append(os.path.join(repo_dir, iconfilename))
348 if os.path.exists(f):
349 logging.info("Deleting disabled build output " + f)
351 if apkfilename in apkcache:
352 del apkcache[apkfilename]
355 def resize_icon(iconpath, density):
357 if not os.path.isfile(iconpath):
362 fp = open(iconpath, 'rb')
364 size = dpi_to_px(density)
366 if any(length > size for length in im.size):
368 im.thumbnail((size, size), Image.ANTIALIAS)
369 logging.debug("%s was too large at %s - new size is %s" % (
370 iconpath, oldsize, im.size))
371 im.save(iconpath, "PNG")
373 except Exception as e:
374 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
381 def resize_all_icons(repodirs):
382 """Resize all icons that exceed the max size
384 :param repodirs: the repo directories to process
386 for repodir in repodirs:
387 for density in screen_densities:
388 icon_dir = get_icon_dir(repodir, density)
389 icon_glob = os.path.join(icon_dir, '*.png')
390 for iconpath in glob.glob(icon_glob):
391 resize_icon(iconpath, density)
395 """ Get the signing certificate of an apk. To get the same md5 has that
396 Android gets, we encode the .RSA certificate in a specific format and pass
397 it hex-encoded to the md5 digest algorithm.
399 :param apkpath: path to the apk
400 :returns: A string containing the md5 of the signature of the apk or None
401 if an error occurred.
404 with zipfile.ZipFile(apkpath, 'r') as apk:
405 certs = [n for n in apk.namelist() if common.CERT_PATH_REGEX.match(n)]
408 logging.error("Found no signing certificates on %s" % apkpath)
411 logging.error("Found multiple signing certificates on %s" % apkpath)
414 cert = apk.read(certs[0])
416 cert_encoded = common.get_certificate(cert)
418 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
421 def get_cache_file():
422 return os.path.join('tmp', 'apkcache')
426 """Get the cached dict of the APK index
428 Gather information about all the apk files in the repo directory,
429 using cached data if possible. Some of the index operations take a
430 long time, like calculating the SHA-256 and verifying the APK
433 The cache is invalidated if the metadata version is different, or
434 the 'allow_disabled_algorithms' config/option is different. In
435 those cases, there is no easy way to know what has changed from
436 the cache, so just rerun the whole thing.
441 apkcachefile = get_cache_file()
442 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
443 if not options.clean and os.path.exists(apkcachefile):
444 with open(apkcachefile, 'rb') as cf:
445 apkcache = pickle.load(cf, encoding='utf-8')
446 if apkcache.get("METADATA_VERSION") != METADATA_VERSION \
447 or apkcache.get('allow_disabled_algorithms') != ada:
452 apkcache["METADATA_VERSION"] = METADATA_VERSION
453 apkcache['allow_disabled_algorithms'] = ada
458 def write_cache(apkcache):
459 apkcachefile = get_cache_file()
460 cache_path = os.path.dirname(apkcachefile)
461 if not os.path.exists(cache_path):
462 os.makedirs(cache_path)
463 with open(apkcachefile, 'wb') as cf:
464 pickle.dump(apkcache, cf)
467 def get_icon_bytes(apkzip, iconsrc):
468 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
470 return apkzip.read(iconsrc)
472 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
475 def sha256sum(filename):
476 '''Calculate the sha256 of the given file'''
477 sha = hashlib.sha256()
478 with open(filename, 'rb') as f:
484 return sha.hexdigest()
487 def has_known_vulnerability(filename):
488 """checks for known vulnerabilities in the APK
490 Checks OpenSSL .so files in the APK to see if they are a known vulnerable
491 version. Google also enforces this:
492 https://support.google.com/faqs/answer/6376725?hl=en
494 Checks whether there are more than one classes.dex or AndroidManifest.xml
495 files, which is invalid and an essential part of the "Master Key" attack.
497 http://www.saurik.com/id/17
500 # statically load this pattern
501 if not hasattr(has_known_vulnerability, "pattern"):
502 has_known_vulnerability.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
505 with zipfile.ZipFile(filename) as zf:
506 for name in zf.namelist():
507 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
510 chunk = lib.read(4096)
513 m = has_known_vulnerability.pattern.search(chunk)
515 version = m.group(1).decode('ascii')
516 if version.startswith('1.0.1') and version[5] >= 'r' \
517 or version.startswith('1.0.2') and version[5] >= 'f':
518 logging.debug('"%s" contains recent %s (%s)', filename, name, version)
520 logging.warning('"%s" contains outdated %s (%s)', filename, name, version)
523 elif name == 'AndroidManifest.xml' or name == 'classes.dex' or name.endswith('.so'):
524 if name in files_in_apk:
526 files_in_apk.add(name)
531 def insert_obbs(repodir, apps, apks):
532 """Scans the .obb files in a given repo directory and adds them to the
533 relevant APK instances. OBB files have versionCodes like APK
534 files, and they are loosely associated. If there is an OBB file
535 present, then any APK with the same or higher versionCode will use
536 that OBB file. There are two OBB types: main and patch, each APK
537 can only have only have one of each.
539 https://developer.android.com/google/play/expansion-files.html
541 :param repodir: repo directory to scan
542 :param apps: list of current, valid apps
543 :param apks: current information on all APKs
547 def obbWarnDelete(f, msg):
548 logging.warning(msg + f)
549 if options.delete_unknown:
550 logging.error("Deleting unknown file: " + f)
554 java_Integer_MIN_VALUE = -pow(2, 31)
555 currentPackageNames = apps.keys()
556 for f in glob.glob(os.path.join(repodir, '*.obb')):
557 obbfile = os.path.basename(f)
558 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
559 chunks = obbfile.split('.')
560 if chunks[0] != 'main' and chunks[0] != 'patch':
561 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
563 if not re.match(r'^-?[0-9]+$', chunks[1]):
564 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
566 versionCode = int(chunks[1])
567 packagename = ".".join(chunks[2:-1])
569 highestVersionCode = java_Integer_MIN_VALUE
570 if packagename not in currentPackageNames:
571 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
574 if packagename == apk['packageName'] and apk['versionCode'] > highestVersionCode:
575 highestVersionCode = apk['versionCode']
576 if versionCode > highestVersionCode:
577 obbWarnDelete(f, 'OBB file has newer versionCode(' + str(versionCode)
578 + ') than any APK: ')
580 obbsha256 = sha256sum(f)
581 obbs.append((packagename, versionCode, obbfile, obbsha256))
584 for (packagename, versionCode, obbfile, obbsha256) in sorted(obbs, reverse=True):
585 if versionCode <= apk['versionCode'] and packagename == apk['packageName']:
586 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
587 apk['obbMainFile'] = obbfile
588 apk['obbMainFileSha256'] = obbsha256
589 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
590 apk['obbPatchFile'] = obbfile
591 apk['obbPatchFileSha256'] = obbsha256
592 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
596 def _get_localized_dict(app, locale):
597 '''get the dict to add localized store metadata to'''
598 if 'localized' not in app:
599 app['localized'] = collections.OrderedDict()
600 if locale not in app['localized']:
601 app['localized'][locale] = collections.OrderedDict()
602 return app['localized'][locale]
605 def _set_localized_text_entry(app, locale, key, f):
606 limit = config['char_limits'][key]
607 localized = _get_localized_dict(app, locale)
609 text = fp.read()[:limit]
611 localized[key] = text
614 def _set_author_entry(app, key, f):
615 limit = config['char_limits']['author']
617 text = fp.read()[:limit]
622 def copy_triple_t_store_metadata(apps):
623 """Include store metadata from the app's source repo
625 The Triple-T Gradle Play Publisher is a plugin that has a standard
626 file layout for all of the metadata and graphics that the Google
627 Play Store accepts. Since F-Droid has the git repo, it can just
628 pluck those files directly. This method reads any text files into
629 the app dict, then copies any graphics into the fdroid repo
632 This needs to be run before insert_localized_app_metadata() so that
633 the graphics files that are copied into the fdroid repo get
636 https://github.com/Triple-T/gradle-play-publisher#upload-images
637 https://github.com/Triple-T/gradle-play-publisher#play-store-metadata
641 if not os.path.isdir('build'):
642 return # nothing to do
644 for packageName, app in apps.items():
645 for d in glob.glob(os.path.join('build', packageName, '*', 'src', '*', 'play')):
646 logging.debug('Triple-T Gradle Play Publisher: ' + d)
647 for root, dirs, files in os.walk(d):
648 segments = root.split('/')
649 locale = segments[-2]
651 if f == 'fulldescription':
652 _set_localized_text_entry(app, locale, 'description',
653 os.path.join(root, f))
655 elif f == 'shortdescription':
656 _set_localized_text_entry(app, locale, 'summary',
657 os.path.join(root, f))
660 _set_localized_text_entry(app, locale, 'name',
661 os.path.join(root, f))
664 _set_localized_text_entry(app, locale, 'video',
665 os.path.join(root, f))
667 elif f == 'whatsnew':
668 _set_localized_text_entry(app, segments[-1], 'whatsNew',
669 os.path.join(root, f))
671 elif f == 'contactEmail':
672 _set_author_entry(app, 'authorEmail', os.path.join(root, f))
674 elif f == 'contactPhone':
675 _set_author_entry(app, 'authorPhone', os.path.join(root, f))
677 elif f == 'contactWebsite':
678 _set_author_entry(app, 'authorWebSite', os.path.join(root, f))
681 base, extension = common.get_extension(f)
682 dirname = os.path.basename(root)
683 if dirname in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
684 if segments[-2] == 'listing':
685 locale = segments[-3]
687 locale = segments[-2]
688 destdir = os.path.join('repo', packageName, locale)
689 os.makedirs(destdir, mode=0o755, exist_ok=True)
690 sourcefile = os.path.join(root, f)
691 destfile = os.path.join(destdir, dirname + '.' + extension)
692 logging.debug('copying ' + sourcefile + ' ' + destfile)
693 shutil.copy(sourcefile, destfile)
696 def insert_localized_app_metadata(apps):
697 """scans standard locations for graphics and localized text
699 Scans for localized description files, store graphics, and
700 screenshot PNG files in statically defined screenshots directory
701 and adds them to the app metadata. The screenshots and graphic
702 must be PNG or JPEG files ending with ".png", ".jpg", or ".jpeg"
703 and must be in the following layout:
704 # TODO replace these docs with link to All_About_Descriptions_Graphics_and_Screenshots
706 repo/packageName/locale/featureGraphic.png
707 repo/packageName/locale/phoneScreenshots/1.png
708 repo/packageName/locale/phoneScreenshots/2.png
710 The changelog files must be text files named with the versionCode
711 ending with ".txt" and must be in the following layout:
712 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#changelogs-whats-new
714 repo/packageName/locale/changelogs/12345.txt
716 This will scan the each app's source repo then the metadata/ dir
717 for these standard locations of changelog files. If it finds
718 them, they will be added to the dict of all packages, with the
719 versions in the metadata/ folder taking precendence over the what
720 is in the app's source repo.
722 Where "packageName" is the app's packageName and "locale" is the locale
723 of the graphics, e.g. what language they are in, using the IETF RFC5646
724 format (en-US, fr-CA, es-MX, etc).
726 This will also scan the app's git for a fastlane folder, and the
727 metadata/ folder and the apps' source repos for standard locations
728 of graphic and screenshot files. If it finds them, it will copy
729 them into the repo. The fastlane files follow this pattern:
730 https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
734 sourcedirs = glob.glob(os.path.join('build', '[A-Za-z]*', 'fastlane', 'metadata', 'android', '[a-z][a-z]*'))
735 sourcedirs += glob.glob(os.path.join('build', '[A-Za-z]*', 'metadata', '[a-z][a-z]*'))
736 sourcedirs += glob.glob(os.path.join('metadata', '[A-Za-z]*', '[a-z][a-z]*'))
738 for srcd in sorted(sourcedirs):
739 if not os.path.isdir(srcd):
741 for root, dirs, files in os.walk(srcd):
742 segments = root.split('/')
743 packageName = segments[1]
744 if packageName not in apps:
745 logging.debug(packageName + ' does not have app metadata, skipping l18n scan.')
747 locale = segments[-1]
748 destdir = os.path.join('repo', packageName, locale)
750 if f in ('description.txt', 'full_description.txt'):
751 _set_localized_text_entry(apps[packageName], locale, 'description',
752 os.path.join(root, f))
754 elif f in ('summary.txt', 'short_description.txt'):
755 _set_localized_text_entry(apps[packageName], locale, 'summary',
756 os.path.join(root, f))
758 elif f in ('name.txt', 'title.txt'):
759 _set_localized_text_entry(apps[packageName], locale, 'name',
760 os.path.join(root, f))
762 elif f == 'video.txt':
763 _set_localized_text_entry(apps[packageName], locale, 'video',
764 os.path.join(root, f))
766 elif f == str(apps[packageName]['CurrentVersionCode']) + '.txt':
767 locale = segments[-2]
768 _set_localized_text_entry(apps[packageName], locale, 'whatsNew',
769 os.path.join(root, f))
772 base, extension = common.get_extension(f)
773 if locale == 'images':
774 locale = segments[-2]
775 destdir = os.path.join('repo', packageName, locale)
776 if base in GRAPHIC_NAMES and extension in ALLOWED_EXTENSIONS:
777 os.makedirs(destdir, mode=0o755, exist_ok=True)
778 logging.debug('copying ' + os.path.join(root, f) + ' ' + destdir)
779 shutil.copy(os.path.join(root, f), destdir)
781 if d in SCREENSHOT_DIRS:
782 for f in glob.glob(os.path.join(root, d, '*.*')):
783 _, extension = common.get_extension(f)
784 if extension in ALLOWED_EXTENSIONS:
785 screenshotdestdir = os.path.join(destdir, d)
786 os.makedirs(screenshotdestdir, mode=0o755, exist_ok=True)
787 logging.debug('copying ' + f + ' ' + screenshotdestdir)
788 shutil.copy(f, screenshotdestdir)
790 repofiles = sorted(glob.glob(os.path.join('repo', '[A-Za-z]*', '[a-z][a-z][A-Z-.@]*')))
792 if not os.path.isdir(d):
794 for f in sorted(glob.glob(os.path.join(d, '*.*')) + glob.glob(os.path.join(d, '*Screenshots', '*.*'))):
795 if not os.path.isfile(f):
797 segments = f.split('/')
798 packageName = segments[1]
800 screenshotdir = segments[3]
801 filename = os.path.basename(f)
802 base, extension = common.get_extension(filename)
804 if packageName not in apps:
805 logging.warning('Found "%s" graphic without metadata for app "%s"!'
806 % (filename, packageName))
808 graphics = _get_localized_dict(apps[packageName], locale)
810 if extension not in ALLOWED_EXTENSIONS:
811 logging.warning('Only PNG and JPEG are supported for graphics, found: ' + f)
812 elif base in GRAPHIC_NAMES:
813 # there can only be zero or one of these per locale
814 graphics[base] = filename
815 elif screenshotdir in SCREENSHOT_DIRS:
816 # there can any number of these per locale
817 logging.debug('adding to ' + screenshotdir + ': ' + f)
818 if screenshotdir not in graphics:
819 graphics[screenshotdir] = []
820 graphics[screenshotdir].append(filename)
822 logging.warning('Unsupported graphics file found: ' + f)
825 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
826 """Scan a repo for all files with an extension except APK/OBB
828 :param apkcache: current cached info about all repo files
829 :param repodir: repo directory to scan
830 :param knownapks: list of all known files, as per metadata.read_metadata
831 :param use_date_from_file: use date from file (instead of current date)
832 for newly added files
837 repodir = repodir.encode('utf-8')
838 for name in os.listdir(repodir):
839 file_extension = common.get_file_extension(name)
840 if file_extension == 'apk' or file_extension == 'obb':
842 filename = os.path.join(repodir, name)
843 name_utf8 = name.decode('utf-8')
844 if filename.endswith(b'_src.tar.gz'):
845 logging.debug('skipping source tarball: ' + filename.decode('utf-8'))
847 if not common.is_repo_file(filename):
849 stat = os.stat(filename)
850 if stat.st_size == 0:
851 raise FDroidException(filename + ' is zero size!')
853 shasum = sha256sum(filename)
856 repo_file = apkcache[name]
857 # added time is cached as tuple but used here as datetime instance
858 if 'added' in repo_file:
859 a = repo_file['added']
860 if isinstance(a, datetime):
861 repo_file['added'] = a
863 repo_file['added'] = datetime(*a[:6])
864 if repo_file.get('hash') == shasum:
865 logging.debug("Reading " + name_utf8 + " from cache")
868 logging.debug("Ignoring stale cache data for " + name)
871 logging.debug("Processing " + name_utf8)
872 repo_file = collections.OrderedDict()
873 repo_file['name'] = os.path.splitext(name_utf8)[0]
874 # TODO rename apkname globally to something more generic
875 repo_file['apkName'] = name_utf8
876 repo_file['hash'] = shasum
877 repo_file['hashType'] = 'sha256'
878 repo_file['versionCode'] = 0
879 repo_file['versionName'] = shasum
880 # the static ID is the SHA256 unless it is set in the metadata
881 repo_file['packageName'] = shasum
883 m = common.STANDARD_FILE_NAME_REGEX.match(name_utf8)
885 repo_file['packageName'] = m.group(1)
886 repo_file['versionCode'] = int(m.group(2))
887 srcfilename = name + b'_src.tar.gz'
888 if os.path.exists(os.path.join(repodir, srcfilename)):
889 repo_file['srcname'] = srcfilename.decode('utf-8')
890 repo_file['size'] = stat.st_size
892 apkcache[name] = repo_file
895 if use_date_from_file:
896 timestamp = stat.st_ctime
897 default_date_param = datetime.fromtimestamp(timestamp).utctimetuple()
899 default_date_param = None
901 # Record in knownapks, getting the added date at the same time..
902 added = knownapks.recordapk(repo_file['apkName'], repo_file['packageName'],
903 default_date=default_date_param)
905 repo_file['added'] = added
907 repo_files.append(repo_file)
909 return repo_files, cachechanged
912 def scan_apk_aapt(apk, apkfile):
913 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
914 if p.returncode != 0:
915 if options.delete_unknown:
916 if os.path.exists(apkfile):
917 logging.error("Failed to get apk information, deleting " + apkfile)
920 logging.error("Could not find {0} to remove it".format(apkfile))
922 logging.error("Failed to get apk information, skipping " + apkfile)
923 raise BuildException("Invalid APK")
924 for line in p.output.splitlines():
925 if line.startswith("package:"):
927 apk['packageName'] = re.match(APK_NAME_PAT, line).group(1)
928 apk['versionCode'] = int(re.match(APK_VERCODE_PAT, line).group(1))
929 apk['versionName'] = re.match(APK_VERNAME_PAT, line).group(1)
930 except Exception as e:
931 raise FDroidException("Package matching failed: " + str(e) + "\nLine was: " + line)
932 elif line.startswith("application:"):
933 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
934 # Keep path to non-dpi icon in case we need it
935 match = re.match(APK_ICON_PAT_NODPI, line)
937 apk['icons_src']['-1'] = match.group(1)
938 elif line.startswith("launchable-activity:"):
939 # Only use launchable-activity as fallback to application
941 apk['name'] = re.match(APK_LABEL_PAT, line).group(1)
942 if '-1' not in apk['icons_src']:
943 match = re.match(APK_ICON_PAT_NODPI, line)
945 apk['icons_src']['-1'] = match.group(1)
946 elif line.startswith("application-icon-"):
947 match = re.match(APK_ICON_PAT, line)
949 density = match.group(1)
950 path = match.group(2)
951 apk['icons_src'][density] = path
952 elif line.startswith("sdkVersion:"):
953 m = re.match(APK_SDK_VERSION_PAT, line)
955 logging.error(line.replace('sdkVersion:', '')
956 + ' is not a valid minSdkVersion!')
958 apk['minSdkVersion'] = m.group(1)
959 # if target not set, default to min
960 if 'targetSdkVersion' not in apk:
961 apk['targetSdkVersion'] = m.group(1)
962 elif line.startswith("targetSdkVersion:"):
963 m = re.match(APK_SDK_VERSION_PAT, line)
965 logging.error(line.replace('targetSdkVersion:', '')
966 + ' is not a valid targetSdkVersion!')
968 apk['targetSdkVersion'] = m.group(1)
969 elif line.startswith("maxSdkVersion:"):
970 apk['maxSdkVersion'] = re.match(APK_SDK_VERSION_PAT, line).group(1)
971 elif line.startswith("native-code:"):
972 apk['nativecode'] = []
973 for arch in line[13:].split(' '):
974 apk['nativecode'].append(arch[1:-1])
975 elif line.startswith('uses-permission:'):
976 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
977 if perm_match['maxSdkVersion']:
978 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
979 permission = UsesPermission(
981 perm_match['maxSdkVersion']
984 apk['uses-permission'].append(permission)
985 elif line.startswith('uses-permission-sdk-23:'):
986 perm_match = re.match(APK_PERMISSION_PAT, line).groupdict()
987 if perm_match['maxSdkVersion']:
988 perm_match['maxSdkVersion'] = int(perm_match['maxSdkVersion'])
989 permission_sdk_23 = UsesPermissionSdk23(
991 perm_match['maxSdkVersion']
994 apk['uses-permission-sdk-23'].append(permission_sdk_23)
996 elif line.startswith('uses-feature:'):
997 feature = re.match(APK_FEATURE_PAT, line).group(1)
998 # Filter out this, it's only added with the latest SDK tools and
999 # causes problems for lots of apps.
1000 if feature != "android.hardware.screen.portrait" \
1001 and feature != "android.hardware.screen.landscape":
1002 if feature.startswith("android.feature."):
1003 feature = feature[16:]
1004 apk['features'].add(feature)
1007 def scan_apk_androguard(apk, apkfile):
1009 from androguard.core.bytecodes.apk import APK
1010 apkobject = APK(apkfile)
1011 if apkobject.is_valid_APK():
1012 arsc = apkobject.get_android_resources()
1014 if options.delete_unknown:
1015 if os.path.exists(apkfile):
1016 logging.error("Failed to get apk information, deleting " + apkfile)
1019 logging.error("Could not find {0} to remove it".format(apkfile))
1021 logging.error("Failed to get apk information, skipping " + apkfile)
1022 raise BuildException("Invaild APK")
1024 raise FDroidException("androguard library is not installed and aapt not present")
1025 except FileNotFoundError:
1026 logging.error("Could not open apk file for analysis")
1027 raise BuildException("Invalid APK")
1029 apk['packageName'] = apkobject.get_package()
1030 apk['versionCode'] = int(apkobject.get_androidversion_code())
1031 apk['versionName'] = apkobject.get_androidversion_name()
1032 if apk['versionName'][0] == "@":
1033 version_id = int(apk['versionName'].replace("@", "0x"), 16)
1034 version_id = arsc.get_id(apk['packageName'], version_id)[1]
1035 apk['versionName'] = arsc.get_string(apk['packageName'], version_id)[1]
1036 apk['name'] = apkobject.get_app_name()
1038 if apkobject.get_max_sdk_version() is not None:
1039 apk['maxSdkVersion'] = apkobject.get_max_sdk_version()
1040 apk['minSdkVersion'] = apkobject.get_min_sdk_version()
1041 apk['targetSdkVersion'] = apkobject.get_target_sdk_version()
1043 icon_id = int(apkobject.get_element("application", "icon").replace("@", "0x"), 16)
1044 icon_name = arsc.get_id(apk['packageName'], icon_id)[1]
1046 density_re = re.compile("^res/(.*)/" + icon_name + ".*$")
1048 for file in apkobject.get_files():
1049 d_re = density_re.match(file)
1051 folder = d_re.group(1).split('-')
1053 resolution = folder[1]
1056 density = screen_resolutions[resolution]
1057 apk['icons_src'][density] = d_re.group(0)
1059 if apk['icons_src'].get('-1') is None:
1060 apk['icons_src']['-1'] = apk['icons_src']['160']
1062 arch_re = re.compile("^lib/(.*)/.*$")
1063 arch = set([arch_re.match(file).group(1) for file in apkobject.get_files() if arch_re.match(file)])
1065 apk['nativecode'] = []
1066 apk['nativecode'].extend(sorted(list(arch)))
1068 xml = apkobject.get_android_manifest_xml()
1070 for item in xml.getElementsByTagName('uses-permission'):
1071 name = str(item.getAttribute("android:name"))
1072 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1073 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1074 permission = UsesPermission(
1078 apk['uses-permission'].append(permission)
1080 for item in xml.getElementsByTagName('uses-permission-sdk-23'):
1081 name = str(item.getAttribute("android:name"))
1082 maxSdkVersion = item.getAttribute("android:maxSdkVersion")
1083 maxSdkVersion = None if maxSdkVersion is '' else int(maxSdkVersion)
1084 permission_sdk_23 = UsesPermissionSdk23(
1088 apk['uses-permission-sdk-23'].append(permission_sdk_23)
1090 for item in xml.getElementsByTagName('uses-feature'):
1091 feature = str(item.getAttribute("android:name"))
1092 if feature != "android.hardware.screen.portrait" \
1093 and feature != "android.hardware.screen.landscape":
1094 if feature.startswith("android.feature."):
1095 feature = feature[16:]
1096 apk['features'].append(feature)
1099 def scan_apk(apkcache, apkfilename, repodir, knownapks, use_date_from_apk=False,
1100 allow_disabled_algorithms=False, archive_bad_sig=False):
1101 """Scan the apk with the given filename in the given repo directory.
1103 This also extracts the icons.
1105 :param apkcache: current apk cache information
1106 :param apkfilename: the filename of the apk to scan
1107 :param repodir: repo directory to scan
1108 :param knownapks: known apks info
1109 :param use_date_from_apk: use date from APK (instead of current date)
1110 for newly added APKs
1111 :param allow_disabled_algorithms: allow APKs with valid signatures that include
1112 disabled algorithms in the signature (e.g. MD5)
1113 :param archive_bad_sig: move APKs with a bad signature to the archive
1114 :returns: (skip, apk, cachechanged) where skip is a boolean indicating whether to skip this apk,
1115 apk is the scanned apk information, and cachechanged is True if the apkcache got changed.
1118 if ' ' in apkfilename:
1119 if options.rename_apks:
1120 newfilename = apkfilename.replace(' ', '_')
1121 os.rename(os.path.join(repodir, apkfilename),
1122 os.path.join(repodir, newfilename))
1123 apkfilename = newfilename
1125 logging.critical("Spaces in filenames are not allowed.")
1126 return True, None, False
1128 apkfile = os.path.join(repodir, apkfilename)
1129 shasum = sha256sum(apkfile)
1131 cachechanged = False
1133 if apkfilename in apkcache:
1134 apk = apkcache[apkfilename]
1135 if apk.get('hash') == shasum:
1136 logging.debug("Reading " + apkfilename + " from cache")
1139 logging.debug("Ignoring stale cache data for " + apkfilename)
1142 logging.debug("Processing " + apkfilename)
1144 apk['hash'] = shasum
1145 apk['hashType'] = 'sha256'
1146 apk['uses-permission'] = []
1147 apk['uses-permission-sdk-23'] = []
1148 apk['features'] = []
1149 apk['icons_src'] = {}
1151 apk['antiFeatures'] = set()
1154 if SdkToolsPopen(['aapt', 'version'], output=False):
1155 scan_apk_aapt(apk, apkfile)
1157 scan_apk_androguard(apk, apkfile)
1158 except BuildException:
1159 return True, None, False
1161 if 'minSdkVersion' not in apk:
1162 logging.warn("No SDK version information found in {0}".format(apkfile))
1163 apk['minSdkVersion'] = 1
1165 # Check for debuggable apks...
1166 if common.isApkAndDebuggable(apkfile):
1167 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
1169 # Get the signature (or md5 of, to be precise)...
1170 logging.debug('Getting signature of {0}'.format(apkfile))
1171 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
1173 logging.critical("Failed to get apk signature")
1174 return True, None, False
1176 if options.rename_apks:
1177 n = apk['packageName'] + '_' + str(apk['versionCode']) + '.apk'
1178 std_short_name = os.path.join(repodir, n)
1179 if apkfile != std_short_name:
1180 if os.path.exists(std_short_name):
1181 std_long_name = std_short_name.replace('.apk', '_' + apk['sig'][:7] + '.apk')
1182 if apkfile != std_long_name:
1183 if os.path.exists(std_long_name):
1184 dupdir = os.path.join('duplicates', repodir)
1185 if not os.path.isdir(dupdir):
1186 os.makedirs(dupdir, exist_ok=True)
1187 dupfile = os.path.join('duplicates', std_long_name)
1188 logging.warning('Moving duplicate ' + std_long_name + ' to ' + dupfile)
1189 os.rename(apkfile, dupfile)
1190 return True, None, False
1192 os.rename(apkfile, std_long_name)
1193 apkfile = std_long_name
1195 os.rename(apkfile, std_short_name)
1196 apkfile = std_short_name
1197 apkfilename = apkfile[len(repodir) + 1:]
1199 apk['apkName'] = apkfilename
1200 srcfilename = apkfilename[:-4] + "_src.tar.gz"
1201 if os.path.exists(os.path.join(repodir, srcfilename)):
1202 apk['srcname'] = srcfilename
1203 apk['size'] = os.path.getsize(apkfile)
1205 # verify the jar signature is correct, allow deprecated
1206 # algorithms only if the APK is in the archive.
1208 if not common.verify_apk_signature(apkfile):
1209 if repodir == 'archive' or allow_disabled_algorithms:
1210 if common.verify_old_apk_signature(apkfile):
1211 apk['antiFeatures'].update(['KnownVuln', 'DisabledAlgorithm'])
1219 logging.warning('Archiving "' + apkfilename + '" with invalid signature!')
1220 move_apk_between_sections(repodir, 'archive', apk)
1222 logging.warning('Skipping "' + apkfilename + '" with invalid signature!')
1223 return True, None, False
1225 if 'KnownVuln' not in apk['antiFeatures']:
1226 if has_known_vulnerability(apkfile):
1227 apk['antiFeatures'].add('KnownVuln')
1229 apkzip = zipfile.ZipFile(apkfile, 'r')
1231 # if an APK has files newer than the system time, suggest updating
1232 # the system clock. This is useful for offline systems, used for
1233 # signing, which do not have another source of clock sync info. It
1234 # has to be more than 24 hours newer because ZIP/APK files do not
1235 # store timezone info
1236 manifest = apkzip.getinfo('AndroidManifest.xml')
1237 if manifest.date_time[1] == 0: # month can't be zero
1238 logging.debug('AndroidManifest.xml has no date')
1240 dt_obj = datetime(*manifest.date_time)
1241 checkdt = dt_obj - timedelta(1)
1242 if datetime.today() < checkdt:
1243 logging.warn('System clock is older than manifest in: '
1245 + '\nSet clock to that time using:\n'
1246 + 'sudo date -s "' + str(dt_obj) + '"')
1248 iconfilename = "%s.%s.png" % (
1252 # Extract the icon file...
1253 empty_densities = []
1254 for density in screen_densities:
1255 if density not in apk['icons_src']:
1256 empty_densities.append(density)
1258 iconsrc = apk['icons_src'][density]
1259 icon_dir = get_icon_dir(repodir, density)
1260 icondest = os.path.join(icon_dir, iconfilename)
1263 with open(icondest, 'wb') as f:
1264 f.write(get_icon_bytes(apkzip, iconsrc))
1265 apk['icons'][density] = iconfilename
1266 except (zipfile.BadZipFile, ValueError, KeyError) as e:
1267 logging.warning("Error retrieving icon file: %s" % (icondest))
1268 del apk['icons_src'][density]
1269 empty_densities.append(density)
1271 if '-1' in apk['icons_src']:
1272 iconsrc = apk['icons_src']['-1']
1273 iconpath = os.path.join(
1274 get_icon_dir(repodir, '0'), iconfilename)
1275 with open(iconpath, 'wb') as f:
1276 f.write(get_icon_bytes(apkzip, iconsrc))
1278 im = Image.open(iconpath)
1279 dpi = px_to_dpi(im.size[0])
1280 for density in screen_densities:
1281 if density in apk['icons']:
1283 if density == screen_densities[-1] or dpi >= int(density):
1284 apk['icons'][density] = iconfilename
1285 shutil.move(iconpath,
1286 os.path.join(get_icon_dir(repodir, density), iconfilename))
1287 empty_densities.remove(density)
1289 except Exception as e:
1290 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
1293 apk['icon'] = iconfilename
1297 # First try resizing down to not lose quality
1299 for density in screen_densities:
1300 if density not in empty_densities:
1301 last_density = density
1303 if last_density is None:
1305 logging.debug("Density %s not available, resizing down from %s"
1306 % (density, last_density))
1308 last_iconpath = os.path.join(
1309 get_icon_dir(repodir, last_density), iconfilename)
1310 iconpath = os.path.join(
1311 get_icon_dir(repodir, density), iconfilename)
1314 fp = open(last_iconpath, 'rb')
1317 size = dpi_to_px(density)
1319 im.thumbnail((size, size), Image.ANTIALIAS)
1320 im.save(iconpath, "PNG")
1321 empty_densities.remove(density)
1322 except Exception as e:
1323 logging.warning("Invalid image file at %s: %s" % (last_iconpath, e))
1328 # Then just copy from the highest resolution available
1330 for density in reversed(screen_densities):
1331 if density not in empty_densities:
1332 last_density = density
1334 if last_density is None:
1336 logging.debug("Density %s not available, copying from lower density %s"
1337 % (density, last_density))
1340 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
1341 os.path.join(get_icon_dir(repodir, density), iconfilename))
1343 empty_densities.remove(density)
1345 for density in screen_densities:
1346 icon_dir = get_icon_dir(repodir, density)
1347 icondest = os.path.join(icon_dir, iconfilename)
1348 resize_icon(icondest, density)
1350 # Copy from icons-mdpi to icons since mdpi is the baseline density
1351 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
1352 if os.path.isfile(baseline):
1353 apk['icons']['0'] = iconfilename
1354 shutil.copyfile(baseline,
1355 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
1357 if use_date_from_apk and manifest.date_time[1] != 0:
1358 default_date_param = datetime(*manifest.date_time)
1360 default_date_param = None
1362 # Record in known apks, getting the added date at the same time..
1363 added = knownapks.recordapk(apk['apkName'], apk['packageName'],
1364 default_date=default_date_param)
1366 apk['added'] = added
1368 apkcache[apkfilename] = apk
1371 return False, apk, cachechanged
1374 def scan_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
1375 """Scan the apks in the given repo directory.
1377 This also extracts the icons.
1379 :param apkcache: current apk cache information
1380 :param repodir: repo directory to scan
1381 :param knownapks: known apks info
1382 :param use_date_from_apk: use date from APK (instead of current date)
1383 for newly added APKs
1384 :returns: (apks, cachechanged) where apks is a list of apk information,
1385 and cachechanged is True if the apkcache got changed.
1388 cachechanged = False
1390 for icon_dir in get_all_icon_dirs(repodir):
1391 if os.path.exists(icon_dir):
1393 shutil.rmtree(icon_dir)
1394 os.makedirs(icon_dir)
1396 os.makedirs(icon_dir)
1399 for apkfile in sorted(glob.glob(os.path.join(repodir, '*.apk'))):
1400 apkfilename = apkfile[len(repodir) + 1:]
1401 ada = options.allow_disabled_algorithms or config['allow_disabled_algorithms']
1402 (skip, apk, cachethis) = scan_apk(apkcache, apkfilename, repodir, knownapks,
1403 use_date_from_apk, ada, True)
1407 cachechanged = cachechanged or cachethis
1409 return apks, cachechanged
1412 def apply_info_from_latest_apk(apps, apks):
1414 Some information from the apks needs to be applied up to the application level.
1415 When doing this, we use the info from the most recent version's apk.
1416 We deal with figuring out when the app was added and last updated at the same time.
1418 for appid, app in apps.items():
1419 bestver = UNSET_VERSION_CODE
1421 if apk['packageName'] == appid:
1422 if apk['versionCode'] > bestver:
1423 bestver = apk['versionCode']
1427 if not app.added or apk['added'] < app.added:
1428 app.added = apk['added']
1429 if not app.lastUpdated or apk['added'] > app.lastUpdated:
1430 app.lastUpdated = apk['added']
1433 logging.debug("Don't know when " + appid + " was added")
1434 if not app.lastUpdated:
1435 logging.debug("Don't know when " + appid + " was last updated")
1437 if bestver == UNSET_VERSION_CODE:
1439 if app.Name is None:
1440 app.Name = app.AutoName or appid
1442 logging.debug("Application " + appid + " has no packages")
1444 if app.Name is None:
1445 app.Name = bestapk['name']
1446 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1447 if app.CurrentVersionCode is None:
1448 app.CurrentVersionCode = str(bestver)
1451 def make_categories_txt(repodir, categories):
1452 '''Write a category list in the repo to allow quick access'''
1454 for cat in sorted(categories):
1455 catdata += cat + '\n'
1456 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1460 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1462 def filter_apk_list_sorted(apk_list):
1464 for apk in apk_list:
1465 if apk['packageName'] == appid:
1468 # Sort the apk list by version code. First is highest/newest.
1469 return sorted(res, key=lambda apk: apk['versionCode'], reverse=True)
1471 for appid, app in apps.items():
1473 if app.ArchivePolicy:
1474 keepversions = int(app.ArchivePolicy[:-9])
1476 keepversions = defaultkeepversions
1478 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1479 .format(appid, len(apks), keepversions, len(archapks)))
1481 current_app_apks = filter_apk_list_sorted(apks)
1482 if len(current_app_apks) > keepversions:
1483 # Move back the ones we don't want.
1484 for apk in current_app_apks[keepversions:]:
1485 move_apk_between_sections(repodir, archivedir, apk)
1486 archapks.append(apk)
1489 current_app_archapks = filter_apk_list_sorted(archapks)
1490 if len(current_app_apks) < keepversions and len(current_app_archapks) > 0:
1492 # Move forward the ones we want again, except DisableAlgorithm
1493 for apk in current_app_archapks:
1494 if 'DisabledAlgorithm' not in apk['antiFeatures']:
1495 move_apk_between_sections(archivedir, repodir, apk)
1496 archapks.remove(apk)
1499 if kept == keepversions:
1503 def move_apk_between_sections(from_dir, to_dir, apk):
1504 """move an APK from repo to archive or vice versa"""
1506 def _move_file(from_dir, to_dir, filename, ignore_missing):
1507 from_path = os.path.join(from_dir, filename)
1508 if ignore_missing and not os.path.exists(from_path):
1510 to_path = os.path.join(to_dir, filename)
1511 if not os.path.exists(to_dir):
1513 shutil.move(from_path, to_path)
1515 if from_dir == to_dir:
1518 logging.info("Moving %s from %s to %s" % (apk['apkName'], from_dir, to_dir))
1519 _move_file(from_dir, to_dir, apk['apkName'], False)
1520 _move_file(from_dir, to_dir, apk['apkName'] + '.asc', True)
1521 for density in all_screen_densities:
1522 from_icon_dir = get_icon_dir(from_dir, density)
1523 to_icon_dir = get_icon_dir(to_dir, density)
1524 if density not in apk['icons']:
1526 _move_file(from_icon_dir, to_icon_dir, apk['icons'][density], True)
1527 if 'srcname' in apk:
1528 _move_file(from_dir, to_dir, apk['srcname'], False)
1531 def add_apks_to_per_app_repos(repodir, apks):
1532 apks_per_app = dict()
1534 apk['per_app_dir'] = os.path.join(apk['packageName'], 'fdroid')
1535 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1536 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1537 apks_per_app[apk['packageName']] = apk
1539 if not os.path.exists(apk['per_app_icons']):
1540 logging.info('Adding new repo for only ' + apk['packageName'])
1541 os.makedirs(apk['per_app_icons'])
1543 apkpath = os.path.join(repodir, apk['apkName'])
1544 shutil.copy(apkpath, apk['per_app_repo'])
1545 apksigpath = apkpath + '.sig'
1546 if os.path.exists(apksigpath):
1547 shutil.copy(apksigpath, apk['per_app_repo'])
1548 apkascpath = apkpath + '.asc'
1549 if os.path.exists(apkascpath):
1550 shutil.copy(apkascpath, apk['per_app_repo'])
1559 global config, options
1561 # Parse command line...
1562 parser = ArgumentParser()
1563 common.setup_global_opts(parser)
1564 parser.add_argument("--create-key", action="store_true", default=False,
1565 help="Create a repo signing key in a keystore")
1566 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1567 help="Create skeleton metadata files that are missing")
1568 parser.add_argument("--delete-unknown", action="store_true", default=False,
1569 help="Delete APKs and/or OBBs without metadata from the repo")
1570 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1571 help="Report on build data status")
1572 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1573 help="Interactively ask about things that need updating.")
1574 parser.add_argument("-I", "--icons", action="store_true", default=False,
1575 help="Resize all the icons exceeding the max pixel size and exit")
1576 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1577 help="Specify editor to use in interactive mode. Default " +
1578 "is /etc/alternatives/editor")
1579 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1580 help="Update the wiki")
1581 parser.add_argument("--pretty", action="store_true", default=False,
1582 help="Produce human-readable index.xml")
1583 parser.add_argument("--clean", action="store_true", default=False,
1584 help="Clean update - don't uses caches, reprocess all apks")
1585 parser.add_argument("--nosign", action="store_true", default=False,
1586 help="When configured for signed indexes, create only unsigned indexes at this stage")
1587 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1588 help="Use date from apk instead of current time for newly added apks")
1589 parser.add_argument("--rename-apks", action="store_true", default=False,
1590 help="Rename APK files that do not match package.name_123.apk")
1591 parser.add_argument("--allow-disabled-algorithms", action="store_true", default=False,
1592 help="Include APKs that are signed with disabled algorithms like MD5")
1593 metadata.add_metadata_arguments(parser)
1594 options = parser.parse_args()
1595 metadata.warnings_action = options.W
1597 config = common.read_config(options)
1599 if not ('jarsigner' in config and 'keytool' in config):
1600 raise FDroidException('Java JDK not found! Install in standard location or set java_paths!')
1603 if config['archive_older'] != 0:
1604 repodirs.append('archive')
1605 if not os.path.exists('archive'):
1609 resize_all_icons(repodirs)
1612 if options.rename_apks:
1613 options.clean = True
1615 # check that icons exist now, rather than fail at the end of `fdroid update`
1616 for k in ['repo_icon', 'archive_icon']:
1618 if not os.path.exists(config[k]):
1619 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1622 # if the user asks to create a keystore, do it now, reusing whatever it can
1623 if options.create_key:
1624 if os.path.exists(config['keystore']):
1625 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1626 logging.critical("\t'" + config['keystore'] + "'")
1629 if 'repo_keyalias' not in config:
1630 config['repo_keyalias'] = socket.getfqdn()
1631 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1632 if 'keydname' not in config:
1633 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1634 common.write_to_config(config, 'keydname', config['keydname'])
1635 if 'keystore' not in config:
1636 config['keystore'] = common.default_config['keystore']
1637 common.write_to_config(config, 'keystore', config['keystore'])
1639 password = common.genpassword()
1640 if 'keystorepass' not in config:
1641 config['keystorepass'] = password
1642 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1643 if 'keypass' not in config:
1644 config['keypass'] = password
1645 common.write_to_config(config, 'keypass', config['keypass'])
1646 common.genkeystore(config)
1649 apps = metadata.read_metadata()
1651 # Generate a list of categories...
1653 for app in apps.values():
1654 categories.update(app.Categories)
1656 # Read known apks data (will be updated and written back when we've finished)
1657 knownapks = common.KnownApks()
1660 apkcache = get_cache()
1662 # Delete builds for disabled apps
1663 delete_disabled_builds(apps, apkcache, repodirs)
1665 # Scan all apks in the main repo
1666 apks, cachechanged = scan_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1668 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1669 options.use_date_from_apk)
1670 cachechanged = cachechanged or fcachechanged
1672 # Generate warnings for apk's with no metadata (or create skeleton
1673 # metadata files, if requested on the command line)
1676 if apk['packageName'] not in apps:
1677 if options.create_metadata:
1678 if 'name' not in apk:
1679 logging.error(apk['packageName'] + ' does not have a name! Skipping...')
1681 f = open(os.path.join('metadata', apk['packageName'] + '.txt'), 'w', encoding='utf8')
1682 f.write("License:Unknown\n")
1683 f.write("Web Site:\n")
1684 f.write("Source Code:\n")
1685 f.write("Issue Tracker:\n")
1686 f.write("Changelog:\n")
1687 f.write("Summary:" + apk['name'] + "\n")
1688 f.write("Description:\n")
1689 f.write(apk['name'] + "\n")
1691 f.write("Name:" + apk['name'] + "\n")
1693 logging.info("Generated skeleton metadata for " + apk['packageName'])
1696 msg = apk['apkName'] + " (" + apk['packageName'] + ") has no metadata!"
1697 if options.delete_unknown:
1698 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkName'])
1699 rmf = os.path.join(repodirs[0], apk['apkName'])
1700 if not os.path.exists(rmf):
1701 logging.error("Could not find {0} to remove it".format(rmf))
1705 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1707 # update the metadata with the newly created ones included
1709 apps = metadata.read_metadata()
1711 copy_triple_t_store_metadata(apps)
1712 insert_obbs(repodirs[0], apps, apks)
1713 insert_localized_app_metadata(apps)
1715 # Scan the archive repo for apks as well
1716 if len(repodirs) > 1:
1717 archapks, cc = scan_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1723 # Apply information from latest apks to the application and update dates
1724 apply_info_from_latest_apk(apps, apks + archapks)
1726 # Sort the app list by name, then the web site doesn't have to by default.
1727 # (we had to wait until we'd scanned the apks to do this, because mostly the
1728 # name comes from there!)
1729 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1731 # APKs are placed into multiple repos based on the app package, providing
1732 # per-app subscription feeds for nightly builds and things like it
1733 if config['per_app_repos']:
1734 add_apks_to_per_app_repos(repodirs[0], apks)
1735 for appid, app in apps.items():
1736 repodir = os.path.join(appid, 'fdroid', 'repo')
1738 appdict[appid] = app
1739 if os.path.isdir(repodir):
1740 index.make(appdict, [appid], apks, repodir, False)
1742 logging.info('Skipping index generation for ' + appid)
1745 if len(repodirs) > 1:
1746 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1748 # Make the index for the main repo...
1749 index.make(apps, sortedids, apks, repodirs[0], False)
1750 make_categories_txt(repodirs[0], categories)
1752 # If there's an archive repo, make the index for it. We already scanned it
1754 if len(repodirs) > 1:
1755 index.make(apps, sortedids, archapks, repodirs[1], True)
1757 git_remote = config.get('binary_transparency_remote')
1758 if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')):
1760 btlog.make_binary_transparency_log(repodirs)
1762 if config['update_stats']:
1763 # Update known apks info...
1764 knownapks.writeifchanged()
1766 # Generate latest apps data for widget
1767 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1769 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1771 appid = line.rstrip()
1772 data += appid + "\t"
1774 data += app.Name + "\t"
1775 if app.icon is not None:
1776 data += app.icon + "\t"
1777 data += app.License + "\n"
1778 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1782 write_cache(apkcache)
1784 # Update the wiki...
1786 update_wiki(apps, sortedids, apks + archapks)
1788 logging.info("Finished.")
1791 if __name__ == "__main__":