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 xml.dom.minidom import Document
34 from argparse import ArgumentParser
38 from pyasn1.error import PyAsn1Error
39 from pyasn1.codec.der import decoder, encoder
40 from pyasn1_modules import rfc2315
41 from binascii import hexlify, unhexlify
47 from . import metadata
48 from .common import FDroidPopen, FDroidPopenBytes, SdkToolsPopen
49 from .metadata import MetaDataException
53 screen_densities = ['640', '480', '320', '240', '160', '120']
55 all_screen_densities = ['0'] + screen_densities
57 UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
58 UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
61 def dpi_to_px(density):
62 return (int(density) * 48) / 160
66 return (int(px) * 160) / 48
69 def get_icon_dir(repodir, density):
71 return os.path.join(repodir, "icons")
72 return os.path.join(repodir, "icons-%s" % density)
75 def get_icon_dirs(repodir):
76 for density in screen_densities:
77 yield get_icon_dir(repodir, density)
80 def get_all_icon_dirs(repodir):
81 for density in all_screen_densities:
82 yield get_icon_dir(repodir, density)
85 def update_wiki(apps, sortedids, apks):
88 :param apps: fully populated list of all applications
89 :param apks: all apks, except...
91 logging.info("Updating wiki")
93 wikiredircat = 'App Redirects'
95 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
96 path=config['wiki_path'])
97 site.login(config['wiki_user'], config['wiki_password'])
99 generated_redirects = {}
101 for appid in sortedids:
102 app = metadata.App(apps[appid])
106 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
108 for af in app.AntiFeatures:
109 wikidata += '{{AntiFeature|' + af + '}}\n'
114 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' % (
117 time.strftime('%Y-%m-%d', app.added) if app.added else '',
118 time.strftime('%Y-%m-%d', app.lastupdated) if app.lastupdated else '',
133 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
135 wikidata += app.Summary
136 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
138 wikidata += "=Description=\n"
139 wikidata += metadata.description_wiki(app.Description) + "\n"
141 wikidata += "=Maintainer Notes=\n"
142 if app.MaintainerNotes:
143 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
144 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)
146 # Get a list of all packages for this application...
148 gotcurrentver = False
152 if apk['id'] == appid:
153 if str(apk['versioncode']) == app.CurrentVersionCode:
156 # Include ones we can't build, as a special case...
157 for build in app.builds:
159 if build.vercode == app.CurrentVersionCode:
161 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
162 apklist.append({'versioncode': int(build.vercode),
163 'version': build.version,
164 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
169 if apk['versioncode'] == int(build.vercode):
174 apklist.append({'versioncode': int(build.vercode),
175 'version': build.version,
176 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.vercode),
178 if app.CurrentVersionCode == '0':
180 # Sort with most recent first...
181 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
183 wikidata += "=Versions=\n"
184 if len(apklist) == 0:
185 wikidata += "We currently have no versions of this app available."
186 elif not gotcurrentver:
187 wikidata += "We don't have the current version of this app."
189 wikidata += "We have the current version of this app."
190 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
191 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
192 if len(app.NoSourceSince) > 0:
193 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
194 if len(app.CurrentVersion) > 0:
195 wikidata += "The current (recommended) version is " + app.CurrentVersion
196 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
199 wikidata += "==" + apk['version'] + "==\n"
201 if 'buildproblem' in apk:
202 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
205 wikidata += "This version is built and signed by "
207 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
209 wikidata += "the original developer.\n\n"
210 wikidata += "Version code: " + str(apk['versioncode']) + '\n'
212 wikidata += '\n[[Category:' + wikicat + ']]\n'
213 if len(app.NoSourceSince) > 0:
214 wikidata += '\n[[Category:Apps missing source code]]\n'
215 if validapks == 0 and not app.Disabled:
216 wikidata += '\n[[Category:Apps with no packages]]\n'
217 if cantupdate and not app.Disabled:
218 wikidata += "\n[[Category:Apps we cannot update]]\n"
219 if buildfails and not app.Disabled:
220 wikidata += "\n[[Category:Apps with failing builds]]\n"
221 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
222 wikidata += '\n[[Category:Apps to Update]]\n'
224 wikidata += '\n[[Category:Apps that are disabled]]\n'
225 if app.UpdateCheckMode == 'None' and not app.Disabled:
226 wikidata += '\n[[Category:Apps with no update check]]\n'
227 for appcat in app.Categories:
228 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
230 # We can't have underscores in the page name, even if they're in
231 # the package ID, because MediaWiki messes with them...
232 pagename = appid.replace('_', ' ')
234 # Drop a trailing newline, because mediawiki is going to drop it anyway
235 # and it we don't we'll think the page has changed when it hasn't...
236 if wikidata.endswith('\n'):
237 wikidata = wikidata[:-1]
239 generated_pages[pagename] = wikidata
241 # Make a redirect from the name to the ID too, unless there's
242 # already an existing page with the name and it isn't a redirect.
244 apppagename = app.Name.replace('_', ' ')
245 apppagename = apppagename.replace('{', '')
246 apppagename = apppagename.replace('}', ' ')
247 apppagename = apppagename.replace(':', ' ')
248 apppagename = apppagename.replace('[', ' ')
249 apppagename = apppagename.replace(']', ' ')
250 # Drop double spaces caused mostly by replacing ':' above
251 apppagename = apppagename.replace(' ', ' ')
252 for expagename in site.allpages(prefix=apppagename,
253 filterredir='nonredirects',
255 if expagename == apppagename:
257 # Another reason not to make the redirect page is if the app name
258 # is the same as it's ID, because that will overwrite the real page
259 # with an redirect to itself! (Although it seems like an odd
260 # scenario this happens a lot, e.g. where there is metadata but no
261 # builds or binaries to extract a name from.
262 if apppagename == pagename:
265 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
267 for tcat, genp in [(wikicat, generated_pages),
268 (wikiredircat, generated_redirects)]:
269 catpages = site.Pages['Category:' + tcat]
271 for page in catpages:
272 existingpages.append(page.name)
273 if page.name in genp:
274 pagetxt = page.edit()
275 if pagetxt != genp[page.name]:
276 logging.debug("Updating modified page " + page.name)
277 page.save(genp[page.name], summary='Auto-updated')
279 logging.debug("Page " + page.name + " is unchanged")
281 logging.warn("Deleting page " + page.name)
282 page.delete('No longer published')
283 for pagename, text in genp.items():
284 logging.debug("Checking " + pagename)
285 if pagename not in existingpages:
286 logging.debug("Creating page " + pagename)
288 newpage = site.Pages[pagename]
289 newpage.save(text, summary='Auto-created')
291 logging.error("...FAILED to create page '{0}'".format(pagename))
293 # Purge server cache to ensure counts are up to date
294 site.pages['Repository Maintenance'].purge()
297 def delete_disabled_builds(apps, apkcache, repodirs):
298 """Delete disabled build outputs.
300 :param apps: list of all applications, as per metadata.read_metadata
301 :param apkcache: current apk cache information
302 :param repodirs: the repo directories to process
304 for appid, app in apps.items():
305 for build in app['builds']:
306 if not build.disable:
308 apkfilename = appid + '_' + str(build.vercode) + '.apk'
309 iconfilename = "%s.%s.png" % (
312 for repodir in repodirs:
314 os.path.join(repodir, apkfilename),
315 os.path.join(repodir, apkfilename + '.asc'),
316 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
318 for density in all_screen_densities:
319 repo_dir = get_icon_dir(repodir, density)
320 files.append(os.path.join(repo_dir, iconfilename))
323 if os.path.exists(f):
324 logging.info("Deleting disabled build output " + f)
326 if apkfilename in apkcache:
327 del apkcache[apkfilename]
330 def resize_icon(iconpath, density):
332 if not os.path.isfile(iconpath):
337 fp = open(iconpath, 'rb')
339 size = dpi_to_px(density)
341 if any(length > size for length in im.size):
343 im.thumbnail((size, size), Image.ANTIALIAS)
344 logging.debug("%s was too large at %s - new size is %s" % (
345 iconpath, oldsize, im.size))
346 im.save(iconpath, "PNG")
348 except Exception as e:
349 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
356 def resize_all_icons(repodirs):
357 """Resize all icons that exceed the max size
359 :param repodirs: the repo directories to process
361 for repodir in repodirs:
362 for density in screen_densities:
363 icon_dir = get_icon_dir(repodir, density)
364 icon_glob = os.path.join(icon_dir, '*.png')
365 for iconpath in glob.glob(icon_glob):
366 resize_icon(iconpath, density)
369 # A signature block file with a .DSA, .RSA, or .EC extension
370 cert_path_regex = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
374 """ Get the signing certificate of an apk. To get the same md5 has that
375 Android gets, we encode the .RSA certificate in a specific format and pass
376 it hex-encoded to the md5 digest algorithm.
378 :param apkpath: path to the apk
379 :returns: A string containing the md5 of the signature of the apk or None
380 if an error occurred.
385 # verify the jar signature is correct
386 args = [config['jarsigner'], '-verify', apkpath]
387 p = FDroidPopen(args)
388 if p.returncode != 0:
389 logging.critical(apkpath + " has a bad signature!")
392 with zipfile.ZipFile(apkpath, 'r') as apk:
394 certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
397 logging.error("Found no signing certificates on %s" % apkpath)
400 logging.error("Found multiple signing certificates on %s" % apkpath)
403 cert = apk.read(certs[0])
405 content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
406 if content.getComponentByName('contentType') != rfc2315.signedData:
407 logging.error("Unexpected format.")
410 content = decoder.decode(content.getComponentByName('content'),
411 asn1Spec=rfc2315.SignedData())[0]
413 certificates = content.getComponentByName('certificates')
415 logging.error("Certificates not found.")
418 cert_encoded = encoder.encode(certificates)[4:]
420 return hashlib.md5(hexlify(cert_encoded)).hexdigest()
423 def get_icon_bytes(apkzip, iconsrc):
424 '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
426 return apkzip.read(iconsrc)
428 return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
431 def sha256sum(filename):
432 '''Calculate the sha256 of the given file'''
433 sha = hashlib.sha256()
434 with open(filename, 'rb') as f:
440 return sha.hexdigest()
443 def has_old_openssl(filename):
444 '''checks for known vulnerable openssl versions in the APK'''
446 # statically load this pattern
447 if not hasattr(has_old_openssl, "pattern"):
448 has_old_openssl.pattern = re.compile(b'.*OpenSSL ([01][0-9a-z.-]+)')
450 with zipfile.ZipFile(filename) as zf:
451 for name in zf.namelist():
452 if name.endswith('libcrypto.so') or name.endswith('libssl.so'):
455 chunk = lib.read(4096)
458 m = has_old_openssl.pattern.search(chunk)
460 version = m.group(1).decode('ascii')
461 if version.startswith('1.0.1') and version[5] >= 'r' \
462 or version.startswith('1.0.2') and version[5] >= 'f':
463 logging.debug('"%s" contains recent %s (%s)', filename, name, version)
465 logging.warning('"%s" contains outdated %s (%s)', filename, name, version)
471 def insert_obbs(repodir, apps, apks):
472 """Scans the .obb files in a given repo directory and adds them to the
473 relevant APK instances. OBB files have versionCodes like APK
474 files, and they are loosely associated. If there is an OBB file
475 present, then any APK with the same or higher versionCode will use
476 that OBB file. There are two OBB types: main and patch, each APK
477 can only have only have one of each.
479 https://developer.android.com/google/play/expansion-files.html
481 :param repodir: repo directory to scan
482 :param apps: list of current, valid apps
483 :param apks: current information on all APKs
487 def obbWarnDelete(f, msg):
488 logging.warning(msg + f)
489 if options.delete_unknown:
490 logging.error("Deleting unknown file: " + f)
494 java_Integer_MIN_VALUE = -pow(2, 31)
495 for f in glob.glob(os.path.join(repodir, '*.obb')):
496 obbfile = os.path.basename(f)
497 # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
498 chunks = obbfile.split('.')
499 if chunks[0] != 'main' and chunks[0] != 'patch':
500 obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
502 if not re.match(r'^-?[0-9]+$', chunks[1]):
503 obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
505 versioncode = int(chunks[1])
506 packagename = ".".join(chunks[2:-1])
508 highestVersionCode = java_Integer_MIN_VALUE
509 if packagename not in apps.keys():
510 obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
513 if packagename == apk['id'] and apk['versioncode'] > highestVersionCode:
514 highestVersionCode = apk['versioncode']
515 if versioncode > highestVersionCode:
516 obbWarnDelete(f, 'OBB file has newer versioncode(' + str(versioncode)
517 + ') than any APK: ')
519 obbsha256 = sha256sum(f)
520 obbs.append((packagename, versioncode, obbfile, obbsha256))
523 for (packagename, versioncode, obbfile, obbsha256) in sorted(obbs, reverse=True):
524 if versioncode <= apk['versioncode'] and packagename == apk['id']:
525 if obbfile.startswith('main.') and 'obbMainFile' not in apk:
526 apk['obbMainFile'] = obbfile
527 apk['obbMainFileSha256'] = obbsha256
528 elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
529 apk['obbPatchFile'] = obbfile
530 apk['obbPatchFileSha256'] = obbsha256
531 if 'obbMainFile' in apk and 'obbPatchFile' in apk:
535 def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False):
536 """Scan a repo for all files with an extension except APK/OBB
538 :param apkcache: current cached info about all repo files
539 :param repodir: repo directory to scan
540 :param knownapks: list of all known files, as per metadata.read_metadata
541 :param use_date_from_file: use date from file (instead of current date)
542 for newly added files
547 for name in os.listdir(repodir):
548 file_extension = common.get_file_extension(name)
549 if file_extension == 'apk' or file_extension == 'obb':
551 filename = os.path.join(repodir, name)
552 if not common.is_repo_file(name):
554 stat = os.stat(filename)
555 if stat.st_size == 0:
556 logging.error(filename + ' is zero size!')
559 shasum = sha256sum(filename)
562 repo_file = apkcache[name]
563 if repo_file['sha256'] == shasum:
564 logging.debug("Reading " + name + " from cache")
567 logging.debug("Ignoring stale cache data for " + name)
570 logging.debug("Processing " + name)
572 # TODO rename apkname globally to something more generic
573 repo_file['name'] = name
574 repo_file['apkname'] = name
575 repo_file['sha256'] = shasum
576 repo_file['versioncode'] = 0
577 repo_file['version'] = shasum
578 # the static ID is the SHA256 unless it is set in the metadata
579 repo_file['id'] = shasum
580 srcfilename = name + ".src.tar.gz"
581 if os.path.exists(os.path.join(repodir, srcfilename)):
582 repo_file['srcname'] = srcfilename
583 repo_file['size'] = stat.st_size
585 apkcache[name] = repo_file
588 if use_date_from_file:
589 timestamp = stat.st_ctime
590 default_date_param = datetime.fromtimestamp(timestamp).utctimetuple()
592 default_date_param = None
594 # Record in knownapks, getting the added date at the same time..
595 added = knownapks.recordapk(repo_file['apkname'], repo_file['id'],
596 default_date=default_date_param)
598 repo_file['added'] = added
600 repo_files.append(repo_file)
602 return repo_files, cachechanged
605 def scan_apks(apkcache, repodir, knownapks, use_date_from_apk=False):
606 """Scan the apks in the given repo directory.
608 This also extracts the icons.
610 :param apkcache: current apk cache information
611 :param repodir: repo directory to scan
612 :param knownapks: known apks info
613 :param use_date_from_apk: use date from APK (instead of current date)
615 :returns: (apks, cachechanged) where apks is a list of apk information,
616 and cachechanged is True if the apkcache got changed.
621 for icon_dir in get_all_icon_dirs(repodir):
622 if os.path.exists(icon_dir):
624 shutil.rmtree(icon_dir)
625 os.makedirs(icon_dir)
627 os.makedirs(icon_dir)
630 name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
631 vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
632 vername_pat = re.compile(".*versionName='([^']*)'.*")
633 label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
634 icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
635 icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
636 sdkversion_pat = re.compile(".*'([0-9]*)'.*")
637 permission_pat = re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
638 feature_pat = re.compile(".*name='([^']*)'.*")
639 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
641 apkfilename = apkfile[len(repodir) + 1:]
642 if ' ' in apkfilename:
643 logging.critical("Spaces in filenames are not allowed.")
646 shasum = sha256sum(apkfile)
649 if apkfilename in apkcache:
650 apk = apkcache[apkfilename]
651 if apk['sha256'] == shasum:
652 logging.debug("Reading " + apkfilename + " from cache")
655 logging.debug("Ignoring stale cache data for " + apkfilename)
658 logging.debug("Processing " + apkfilename)
660 apk['apkname'] = apkfilename
661 apk['sha256'] = shasum
662 srcfilename = apkfilename[:-4] + "_src.tar.gz"
663 if os.path.exists(os.path.join(repodir, srcfilename)):
664 apk['srcname'] = srcfilename
665 apk['size'] = os.path.getsize(apkfile)
666 apk['uses-permission'] = set()
667 apk['uses-permission-sdk-23'] = set()
668 apk['features'] = set()
669 apk['icons_src'] = {}
671 apk['antiFeatures'] = set()
672 if has_old_openssl(apkfile):
673 apk['antiFeatures'].add('KnownVuln')
674 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
675 if p.returncode != 0:
676 if options.delete_unknown:
677 if os.path.exists(apkfile):
678 logging.error("Failed to get apk information, deleting " + apkfile)
681 logging.error("Could not find {0} to remove it".format(apkfile))
683 logging.error("Failed to get apk information, skipping " + apkfile)
685 for line in p.output.splitlines():
686 if line.startswith("package:"):
688 apk['id'] = re.match(name_pat, line).group(1)
689 apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
690 apk['version'] = re.match(vername_pat, line).group(1)
691 except Exception as e:
692 logging.error("Package matching failed: " + str(e))
693 logging.info("Line was: " + line)
695 elif line.startswith("application:"):
696 apk['name'] = re.match(label_pat, line).group(1)
697 # Keep path to non-dpi icon in case we need it
698 match = re.match(icon_pat_nodpi, line)
700 apk['icons_src']['-1'] = match.group(1)
701 elif line.startswith("launchable-activity:"):
702 # Only use launchable-activity as fallback to application
704 apk['name'] = re.match(label_pat, line).group(1)
705 if '-1' not in apk['icons_src']:
706 match = re.match(icon_pat_nodpi, line)
708 apk['icons_src']['-1'] = match.group(1)
709 elif line.startswith("application-icon-"):
710 match = re.match(icon_pat, line)
712 density = match.group(1)
713 path = match.group(2)
714 apk['icons_src'][density] = path
715 elif line.startswith("sdkVersion:"):
716 m = re.match(sdkversion_pat, line)
718 logging.error(line.replace('sdkVersion:', '')
719 + ' is not a valid minSdkVersion!')
721 apk['minSdkVersion'] = m.group(1)
722 # if target not set, default to min
723 if 'targetSdkVersion' not in apk:
724 apk['targetSdkVersion'] = m.group(1)
725 elif line.startswith("targetSdkVersion:"):
726 m = re.match(sdkversion_pat, line)
728 logging.error(line.replace('targetSdkVersion:', '')
729 + ' is not a valid targetSdkVersion!')
731 apk['targetSdkVersion'] = m.group(1)
732 elif line.startswith("maxSdkVersion:"):
733 apk['maxSdkVersion'] = re.match(sdkversion_pat, line).group(1)
734 elif line.startswith("native-code:"):
735 apk['nativecode'] = []
736 for arch in line[13:].split(' '):
737 apk['nativecode'].append(arch[1:-1])
738 elif line.startswith('uses-permission:'):
739 perm_match = re.match(permission_pat, line).groupdict()
741 permission = UsesPermission(
743 perm_match['maxSdkVersion']
746 apk['uses-permission'].add(permission)
747 elif line.startswith('uses-permission-sdk-23:'):
748 perm_match = re.match(permission_pat, line).groupdict()
750 permission_sdk_23 = UsesPermissionSdk23(
752 perm_match['maxSdkVersion']
755 apk['uses-permission-sdk-23'].add(permission_sdk_23)
757 elif line.startswith('uses-feature:'):
758 feature = re.match(feature_pat, line).group(1)
759 # Filter out this, it's only added with the latest SDK tools and
760 # causes problems for lots of apps.
761 if feature != "android.hardware.screen.portrait" \
762 and feature != "android.hardware.screen.landscape":
763 if feature.startswith("android.feature."):
764 feature = feature[16:]
765 apk['features'].add(feature)
767 if 'minSdkVersion' not in apk:
768 logging.warn("No SDK version information found in {0}".format(apkfile))
769 apk['minSdkVersion'] = 1
771 # Check for debuggable apks...
772 if common.isApkAndDebuggable(apkfile, config):
773 logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
775 # Get the signature (or md5 of, to be precise)...
776 logging.debug('Getting signature of {0}'.format(apkfile))
777 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
779 logging.critical("Failed to get apk signature")
782 apkzip = zipfile.ZipFile(apkfile, 'r')
784 # if an APK has files newer than the system time, suggest updating
785 # the system clock. This is useful for offline systems, used for
786 # signing, which do not have another source of clock sync info. It
787 # has to be more than 24 hours newer because ZIP/APK files do not
788 # store timezone info
789 manifest = apkzip.getinfo('AndroidManifest.xml')
790 if manifest.date_time[1] == 0: # month can't be zero
791 logging.debug('AndroidManifest.xml has no date')
793 dt_obj = datetime(*manifest.date_time)
794 checkdt = dt_obj - timedelta(1)
795 if datetime.today() < checkdt:
796 logging.warn('System clock is older than manifest in: '
798 + '\nSet clock to that time using:\n'
799 + 'sudo date -s "' + str(dt_obj) + '"')
801 iconfilename = "%s.%s.png" % (
805 # Extract the icon file...
807 for density in screen_densities:
808 if density not in apk['icons_src']:
809 empty_densities.append(density)
811 iconsrc = apk['icons_src'][density]
812 icon_dir = get_icon_dir(repodir, density)
813 icondest = os.path.join(icon_dir, iconfilename)
816 with open(icondest, 'wb') as f:
817 f.write(get_icon_bytes(apkzip, iconsrc))
818 apk['icons'][density] = iconfilename
821 logging.warn("Error retrieving icon file")
822 del apk['icons'][density]
823 del apk['icons_src'][density]
824 empty_densities.append(density)
826 if '-1' in apk['icons_src']:
827 iconsrc = apk['icons_src']['-1']
828 iconpath = os.path.join(
829 get_icon_dir(repodir, '0'), iconfilename)
830 with open(iconpath, 'wb') as f:
831 f.write(get_icon_bytes(apkzip, iconsrc))
833 im = Image.open(iconpath)
834 dpi = px_to_dpi(im.size[0])
835 for density in screen_densities:
836 if density in apk['icons']:
838 if density == screen_densities[-1] or dpi >= int(density):
839 apk['icons'][density] = iconfilename
840 shutil.move(iconpath,
841 os.path.join(get_icon_dir(repodir, density), iconfilename))
842 empty_densities.remove(density)
844 except Exception as e:
845 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
848 apk['icon'] = iconfilename
852 # First try resizing down to not lose quality
854 for density in screen_densities:
855 if density not in empty_densities:
856 last_density = density
858 if last_density is None:
860 logging.debug("Density %s not available, resizing down from %s"
861 % (density, last_density))
863 last_iconpath = os.path.join(
864 get_icon_dir(repodir, last_density), iconfilename)
865 iconpath = os.path.join(
866 get_icon_dir(repodir, density), iconfilename)
869 fp = open(last_iconpath, 'rb')
872 size = dpi_to_px(density)
874 im.thumbnail((size, size), Image.ANTIALIAS)
875 im.save(iconpath, "PNG")
876 empty_densities.remove(density)
878 logging.warning("Invalid image file at %s" % last_iconpath)
883 # Then just copy from the highest resolution available
885 for density in reversed(screen_densities):
886 if density not in empty_densities:
887 last_density = density
889 if last_density is None:
891 logging.debug("Density %s not available, copying from lower density %s"
892 % (density, last_density))
895 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
896 os.path.join(get_icon_dir(repodir, density), iconfilename))
898 empty_densities.remove(density)
900 for density in screen_densities:
901 icon_dir = get_icon_dir(repodir, density)
902 icondest = os.path.join(icon_dir, iconfilename)
903 resize_icon(icondest, density)
905 # Copy from icons-mdpi to icons since mdpi is the baseline density
906 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
907 if os.path.isfile(baseline):
908 apk['icons']['0'] = iconfilename
909 shutil.copyfile(baseline,
910 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
912 if use_date_from_apk and manifest.date_time[1] != 0:
913 default_date_param = datetime(*manifest.date_time).utctimetuple()
915 default_date_param = None
917 # Record in known apks, getting the added date at the same time..
918 added = knownapks.recordapk(apk['apkname'], apk['id'], default_date=default_date_param)
922 apkcache[apkfilename] = apk
927 return apks, cachechanged
930 repo_pubkey_fingerprint = None
933 # Generate a certificate fingerprint the same way keytool does it
934 # (but with slightly different formatting)
935 def cert_fingerprint(data):
936 digest = hashlib.sha256(data).digest()
938 ret.append(' '.join("%02X" % b for b in bytearray(digest)))
942 def extract_pubkey():
943 global repo_pubkey_fingerprint
944 if 'repo_pubkey' in config:
945 pubkey = unhexlify(config['repo_pubkey'])
947 p = FDroidPopenBytes([config['keytool'], '-exportcert',
948 '-alias', config['repo_keyalias'],
949 '-keystore', config['keystore'],
950 '-storepass:file', config['keystorepassfile']]
951 + config['smartcardoptions'],
952 output=False, stderr_to_stdout=False)
953 if p.returncode != 0 or len(p.output) < 20:
954 msg = "Failed to get repo pubkey!"
955 if config['keystore'] == 'NONE':
956 msg += ' Is your crypto smartcard plugged in?'
957 logging.critical(msg)
960 repo_pubkey_fingerprint = cert_fingerprint(pubkey)
961 return hexlify(pubkey)
964 # Get raw URL from git service for mirroring
965 def get_raw_mirror(url):
966 # Divide urls in parts
972 # fdroidserver will use always 'master' branch for git-mirroring
976 if hostname == "github.com":
977 # Github like RAW url "https://raw.githubusercontent.com/user/repo/master/fdroid"
978 url[2] = "raw.githubusercontent.com"
979 url.extend([branch, folder])
980 elif hostname == "gitlab.com":
981 # Gitlab like RAW url "https://gitlab.com/user/repo/raw/master/fdroid"
982 url.extend(["raw", branch, folder])
990 def make_index(apps, sortedids, apks, repodir, archive):
991 """Generate the repo index files.
993 :param apps: fully populated apps list
994 :param apks: full populated apks list
995 :param repodir: the repo directory
996 :param archive: True if this is the archive repo, False if it's the
998 :param categories: list of categories
1003 def addElement(name, value, doc, parent):
1004 el = doc.createElement(name)
1005 el.appendChild(doc.createTextNode(value))
1006 parent.appendChild(el)
1008 def addElementNonEmpty(name, value, doc, parent):
1011 addElement(name, value, doc, parent)
1013 def addElementIfInApk(name, apk, key, doc, parent):
1016 value = str(apk[key])
1017 addElement(name, value, doc, parent)
1019 def addElementCDATA(name, value, doc, parent):
1020 el = doc.createElement(name)
1021 el.appendChild(doc.createCDATASection(value))
1022 parent.appendChild(el)
1024 root = doc.createElement("fdroid")
1025 doc.appendChild(root)
1027 repoel = doc.createElement("repo")
1029 mirrorcheckfailed = False
1031 for mirror in sorted(config.get('mirrors', [])):
1032 base = os.path.basename(urllib.parse.urlparse(mirror).path.rstrip('/'))
1033 if config.get('nonstandardwebroot') is not True and base != 'fdroid':
1034 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
1035 mirrorcheckfailed = True
1036 # must end with / or urljoin strips a whole path segment
1037 if mirror.endswith('/'):
1038 mirrors.append(mirror)
1040 mirrors.append(mirror + '/')
1041 for mirror in config.get('servergitmirrors', []):
1042 mirror = get_raw_mirror(mirror)
1043 if mirror is not None:
1044 mirrors.append(mirror + '/')
1045 if mirrorcheckfailed:
1049 repoel.setAttribute("name", config['archive_name'])
1050 if config['repo_maxage'] != 0:
1051 repoel.setAttribute("maxage", str(config['repo_maxage']))
1052 repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
1053 repoel.setAttribute("url", config['archive_url'])
1054 addElement('description', config['archive_description'], doc, repoel)
1055 urlbasepath = os.path.basename(urllib.parse.urlparse(config['archive_url']).path)
1056 for mirror in mirrors:
1057 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
1060 repoel.setAttribute("name", config['repo_name'])
1061 if config['repo_maxage'] != 0:
1062 repoel.setAttribute("maxage", str(config['repo_maxage']))
1063 repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
1064 repoel.setAttribute("url", config['repo_url'])
1065 addElement('description', config['repo_description'], doc, repoel)
1066 urlbasepath = os.path.basename(urllib.parse.urlparse(config['repo_url']).path)
1067 for mirror in mirrors:
1068 addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
1070 repoel.setAttribute("version", str(METADATA_VERSION))
1071 repoel.setAttribute("timestamp", str(int(time.time())))
1073 nosigningkey = False
1074 if not options.nosign:
1075 if 'repo_keyalias' not in config:
1077 logging.critical("'repo_keyalias' not found in config.py!")
1078 if 'keystore' not in config:
1080 logging.critical("'keystore' not found in config.py!")
1081 if 'keystorepass' not in config and 'keystorepassfile' not in config:
1083 logging.critical("'keystorepass' not found in config.py!")
1084 if 'keypass' not in config and 'keypassfile' not in config:
1086 logging.critical("'keypass' not found in config.py!")
1087 if not os.path.exists(config['keystore']):
1089 logging.critical("'" + config['keystore'] + "' does not exist!")
1091 logging.warning("`fdroid update` requires a signing key, you can create one using:")
1092 logging.warning("\tfdroid update --create-key")
1095 repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
1096 root.appendChild(repoel)
1098 for command in ('install', 'uninstall'):
1100 key = command + '_list'
1102 if isinstance(config[key], str):
1103 packageNames = [config[key]]
1104 elif all(isinstance(item, str) for item in config[key]):
1105 packageNames = config[key]
1107 raise TypeError('only accepts strings, lists, and tuples')
1108 for packageName in packageNames:
1109 element = doc.createElement(command)
1110 root.appendChild(element)
1111 element.setAttribute('packageName', packageName)
1113 for appid in sortedids:
1114 app = metadata.App(apps[appid])
1116 if app.Disabled is not None:
1119 # Get a list of the apks for this app...
1122 if apk['id'] == appid:
1125 if len(apklist) == 0:
1128 apel = doc.createElement("application")
1129 apel.setAttribute("id", app.id)
1130 root.appendChild(apel)
1132 addElement('id', app.id, doc, apel)
1134 addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
1136 addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
1137 addElement('name', app.Name, doc, apel)
1138 addElement('summary', app.Summary, doc, apel)
1140 addElement('icon', app.icon, doc, apel)
1144 return ("fdroid.app:" + appid, apps[appid].Name)
1145 raise MetaDataException("Cannot resolve app id " + appid)
1148 metadata.description_html(app.Description, linkres),
1150 addElement('license', app.License, doc, apel)
1152 addElement('categories', ','.join(app.Categories), doc, apel)
1153 # We put the first (primary) category in LAST, which will have
1154 # the desired effect of making clients that only understand one
1155 # category see that one.
1156 addElement('category', app.Categories[0], doc, apel)
1157 addElement('web', app.WebSite, doc, apel)
1158 addElement('source', app.SourceCode, doc, apel)
1159 addElement('tracker', app.IssueTracker, doc, apel)
1160 addElementNonEmpty('changelog', app.Changelog, doc, apel)
1161 addElementNonEmpty('author', app.AuthorName, doc, apel)
1162 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
1163 addElementNonEmpty('donate', app.Donate, doc, apel)
1164 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
1165 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
1166 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
1168 # These elements actually refer to the current version (i.e. which
1169 # one is recommended. They are historically mis-named, and need
1170 # changing, but stay like this for now to support existing clients.
1171 addElement('marketversion', app.CurrentVersion, doc, apel)
1172 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
1175 pv = app.Provides.split(',')
1176 addElementNonEmpty('provides', ','.join(pv), doc, apel)
1177 if app.RequiresRoot:
1178 addElement('requirements', 'root', doc, apel)
1180 # Sort the apk list into version order, just so the web site
1181 # doesn't have to do any work by default...
1182 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
1184 if 'antiFeatures' in apklist[0]:
1185 app.AntiFeatures.extend(apklist[0]['antiFeatures'])
1186 if app.AntiFeatures:
1187 addElementNonEmpty('antifeatures', ','.join(app.AntiFeatures), doc, apel)
1189 # Check for duplicates - they will make the client unhappy...
1190 for i in range(len(apklist) - 1):
1191 if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
1192 logging.critical("duplicate versions: '%s' - '%s'" % (
1193 apklist[i]['apkname'], apklist[i + 1]['apkname']))
1196 current_version_code = 0
1197 current_version_file = None
1199 file_extension = common.get_file_extension(apk['apkname'])
1200 # find the APK for the "Current Version"
1201 if current_version_code < apk['versioncode']:
1202 current_version_code = apk['versioncode']
1203 if current_version_code < int(app.CurrentVersionCode):
1204 current_version_file = apk['apkname']
1206 apkel = doc.createElement("package")
1207 apel.appendChild(apkel)
1208 addElement('version', apk['version'], doc, apkel)
1209 addElement('versioncode', str(apk['versioncode']), doc, apkel)
1210 addElement('apkname', apk['apkname'], doc, apkel)
1211 addElementIfInApk('srcname', apk, 'srcname', doc, apkel)
1212 for hash_type in ['sha256']:
1213 if hash_type not in apk:
1215 hashel = doc.createElement("hash")
1216 hashel.setAttribute("type", hash_type)
1217 hashel.appendChild(doc.createTextNode(apk[hash_type]))
1218 apkel.appendChild(hashel)
1219 addElement('size', str(apk['size']), doc, apkel)
1220 addElement('sdkver', str(apk['minSdkVersion']), doc, apkel)
1221 addElementIfInApk('targetSdkVersion', apk,
1222 'targetSdkVersion', doc, apkel)
1223 addElementIfInApk('maxsdkver', apk,
1224 'maxSdkVersion', doc, apkel)
1225 addElementIfInApk('obbMainFile', apk,
1226 'obbMainFile', doc, apkel)
1227 addElementIfInApk('obbMainFileSha256', apk,
1228 'obbMainFileSha256', doc, apkel)
1229 addElementIfInApk('obbPatchFile', apk,
1230 'obbPatchFile', doc, apkel)
1231 addElementIfInApk('obbPatchFileSha256', apk,
1232 'obbPatchFileSha256', doc, apkel)
1234 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
1236 if file_extension == 'apk': # sig is required for APKs, but only APKs
1237 addElement('sig', apk['sig'], doc, apkel)
1239 old_permissions = set()
1240 sorted_permissions = sorted(apk['uses-permission'])
1241 for perm in sorted_permissions:
1242 perm_name = perm.name
1243 if perm_name.startswith("android.permission."):
1244 perm_name = perm_name[19:]
1245 old_permissions.add(perm_name)
1246 addElementNonEmpty('permissions', ','.join(old_permissions), doc, apkel)
1248 for permission in sorted_permissions:
1249 permel = doc.createElement('uses-permission')
1250 permel.setAttribute('name', permission.name)
1251 if permission.maxSdkVersion is not None:
1252 permel.setAttribute('maxSdkVersion', permission.maxSdkVersion)
1253 apkel.appendChild(permel)
1254 for permission_sdk_23 in sorted(apk['uses-permission-sdk-23']):
1255 permel = doc.createElement('uses-permission-sdk-23')
1256 permel.setAttribute('name', permission_sdk_23.name)
1257 if permission_sdk_23.maxSdkVersion is not None:
1258 permel.setAttribute('maxSdkVersion', permission_sdk_23.maxSdkVersion)
1259 apkel.appendChild(permel)
1260 if 'nativecode' in apk:
1261 addElement('nativecode', ','.join(sorted(apk['nativecode'])), doc, apkel)
1262 addElementNonEmpty('features', ','.join(sorted(apk['features'])), doc, apkel)
1264 if current_version_file is not None \
1265 and config['make_current_version_link'] \
1266 and repodir == 'repo': # only create these
1267 namefield = config['current_version_name_source']
1268 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get(namefield))
1269 apklinkname = sanitized_name + '.apk'
1270 current_version_path = os.path.join(repodir, current_version_file)
1271 if os.path.islink(apklinkname):
1272 os.remove(apklinkname)
1273 os.symlink(current_version_path, apklinkname)
1274 # also symlink gpg signature, if it exists
1275 for extension in ('.asc', '.sig'):
1276 sigfile_path = current_version_path + extension
1277 if os.path.exists(sigfile_path):
1278 siglinkname = apklinkname + extension
1279 if os.path.islink(siglinkname):
1280 os.remove(siglinkname)
1281 os.symlink(sigfile_path, siglinkname)
1284 output = doc.toprettyxml(encoding='utf-8')
1286 output = doc.toxml(encoding='utf-8')
1288 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
1291 if 'repo_keyalias' in config:
1294 logging.info("Creating unsigned index in preparation for signing")
1296 logging.info("Creating signed index with this key (SHA256):")
1297 logging.info("%s" % repo_pubkey_fingerprint)
1299 # Create a jar of the index...
1300 jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
1301 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
1302 if p.returncode != 0:
1303 logging.critical("Failed to create {0}".format(jar_output))
1307 signed = os.path.join(repodir, 'index.jar')
1309 # Remove old signed index if not signing
1310 if os.path.exists(signed):
1313 args = [config['jarsigner'], '-keystore', config['keystore'],
1314 '-storepass:file', config['keystorepassfile'],
1315 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
1316 signed, config['repo_keyalias']]
1317 if config['keystore'] == 'NONE':
1318 args += config['smartcardoptions']
1319 else: # smardcards never use -keypass
1320 args += ['-keypass:file', config['keypassfile']]
1321 p = FDroidPopen(args)
1322 if p.returncode != 0:
1323 logging.critical("Failed to sign index")
1326 # Copy the repo icon into the repo directory...
1327 icon_dir = os.path.join(repodir, 'icons')
1328 iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
1329 shutil.copyfile(config['repo_icon'], iconfilename)
1332 def make_categories_txt(repodir, categories):
1333 '''Write a category list in the repo to allow quick access'''
1335 for cat in sorted(categories):
1336 catdata += cat + '\n'
1337 with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
1341 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1343 for appid, app in apps.items():
1345 if app.ArchivePolicy:
1346 keepversions = int(app.ArchivePolicy[:-9])
1348 keepversions = defaultkeepversions
1350 def filter_apk_list_sorted(apk_list):
1352 for apk in apk_list:
1353 if apk['id'] == appid:
1356 # Sort the apk list by version code. First is highest/newest.
1357 return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
1359 def move_file(from_dir, to_dir, filename, ignore_missing):
1360 from_path = os.path.join(from_dir, filename)
1361 if ignore_missing and not os.path.exists(from_path):
1363 to_path = os.path.join(to_dir, filename)
1364 shutil.move(from_path, to_path)
1366 logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
1367 .format(appid, len(apks), keepversions, len(archapks)))
1369 if len(apks) > keepversions:
1370 apklist = filter_apk_list_sorted(apks)
1371 # Move back the ones we don't want.
1372 for apk in apklist[keepversions:]:
1373 logging.info("Moving " + apk['apkname'] + " to archive")
1374 move_file(repodir, archivedir, apk['apkname'], False)
1375 move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
1376 for density in all_screen_densities:
1377 repo_icon_dir = get_icon_dir(repodir, density)
1378 archive_icon_dir = get_icon_dir(archivedir, density)
1379 if density not in apk['icons']:
1381 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1382 if 'srcname' in apk:
1383 move_file(repodir, archivedir, apk['srcname'], False)
1384 archapks.append(apk)
1386 elif len(apks) < keepversions and len(archapks) > 0:
1387 required = keepversions - len(apks)
1388 archapklist = filter_apk_list_sorted(archapks)
1389 # Move forward the ones we want again.
1390 for apk in archapklist[:required]:
1391 logging.info("Moving " + apk['apkname'] + " from archive")
1392 move_file(archivedir, repodir, apk['apkname'], False)
1393 move_file(archivedir, repodir, apk['apkname'] + '.asc', True)
1394 for density in all_screen_densities:
1395 repo_icon_dir = get_icon_dir(repodir, density)
1396 archive_icon_dir = get_icon_dir(archivedir, density)
1397 if density not in apk['icons']:
1399 move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1400 if 'srcname' in apk:
1401 move_file(archivedir, repodir, apk['srcname'], False)
1402 archapks.remove(apk)
1406 def add_apks_to_per_app_repos(repodir, apks):
1407 apks_per_app = dict()
1409 apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
1410 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1411 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1412 apks_per_app[apk['id']] = apk
1414 if not os.path.exists(apk['per_app_icons']):
1415 logging.info('Adding new repo for only ' + apk['id'])
1416 os.makedirs(apk['per_app_icons'])
1418 apkpath = os.path.join(repodir, apk['apkname'])
1419 shutil.copy(apkpath, apk['per_app_repo'])
1420 apksigpath = apkpath + '.sig'
1421 if os.path.exists(apksigpath):
1422 shutil.copy(apksigpath, apk['per_app_repo'])
1423 apkascpath = apkpath + '.asc'
1424 if os.path.exists(apkascpath):
1425 shutil.copy(apkascpath, apk['per_app_repo'])
1434 global config, options
1436 # Parse command line...
1437 parser = ArgumentParser()
1438 common.setup_global_opts(parser)
1439 parser.add_argument("--create-key", action="store_true", default=False,
1440 help="Create a repo signing key in a keystore")
1441 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1442 help="Create skeleton metadata files that are missing")
1443 parser.add_argument("--delete-unknown", action="store_true", default=False,
1444 help="Delete APKs and/or OBBs without metadata from the repo")
1445 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1446 help="Report on build data status")
1447 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1448 help="Interactively ask about things that need updating.")
1449 parser.add_argument("-I", "--icons", action="store_true", default=False,
1450 help="Resize all the icons exceeding the max pixel size and exit")
1451 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1452 help="Specify editor to use in interactive mode. Default " +
1453 "is /etc/alternatives/editor")
1454 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1455 help="Update the wiki")
1456 parser.add_argument("--pretty", action="store_true", default=False,
1457 help="Produce human-readable index.xml")
1458 parser.add_argument("--clean", action="store_true", default=False,
1459 help="Clean update - don't uses caches, reprocess all apks")
1460 parser.add_argument("--nosign", action="store_true", default=False,
1461 help="When configured for signed indexes, create only unsigned indexes at this stage")
1462 parser.add_argument("--use-date-from-apk", action="store_true", default=False,
1463 help="Use date from apk instead of current time for newly added apks")
1464 metadata.add_metadata_arguments(parser)
1465 options = parser.parse_args()
1466 metadata.warnings_action = options.W
1468 config = common.read_config(options)
1470 if not ('jarsigner' in config and 'keytool' in config):
1471 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1475 if config['archive_older'] != 0:
1476 repodirs.append('archive')
1477 if not os.path.exists('archive'):
1481 resize_all_icons(repodirs)
1484 # check that icons exist now, rather than fail at the end of `fdroid update`
1485 for k in ['repo_icon', 'archive_icon']:
1487 if not os.path.exists(config[k]):
1488 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1491 # if the user asks to create a keystore, do it now, reusing whatever it can
1492 if options.create_key:
1493 if os.path.exists(config['keystore']):
1494 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1495 logging.critical("\t'" + config['keystore'] + "'")
1498 if 'repo_keyalias' not in config:
1499 config['repo_keyalias'] = socket.getfqdn()
1500 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1501 if 'keydname' not in config:
1502 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1503 common.write_to_config(config, 'keydname', config['keydname'])
1504 if 'keystore' not in config:
1505 config['keystore'] = common.default_config.keystore
1506 common.write_to_config(config, 'keystore', config['keystore'])
1508 password = common.genpassword()
1509 if 'keystorepass' not in config:
1510 config['keystorepass'] = password
1511 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1512 if 'keypass' not in config:
1513 config['keypass'] = password
1514 common.write_to_config(config, 'keypass', config['keypass'])
1515 common.genkeystore(config)
1518 apps = metadata.read_metadata()
1520 # Generate a list of categories...
1522 for app in apps.values():
1523 categories.update(app.Categories)
1525 # Read known apks data (will be updated and written back when we've finished)
1526 knownapks = common.KnownApks()
1528 # Gather information about all the apk files in the repo directory, using
1529 # cached data if possible.
1530 apkcachefile = os.path.join('tmp', 'apkcache')
1531 if not options.clean and os.path.exists(apkcachefile):
1532 with open(apkcachefile, 'rb') as cf:
1533 apkcache = pickle.load(cf, encoding='utf-8')
1534 if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
1539 delete_disabled_builds(apps, apkcache, repodirs)
1541 # Scan all apks in the main repo
1542 apks, cachechanged = scan_apks(apkcache, repodirs[0], knownapks, options.use_date_from_apk)
1544 files, fcachechanged = scan_repo_files(apkcache, repodirs[0], knownapks,
1545 options.use_date_from_apk)
1546 cachechanged = cachechanged or fcachechanged
1548 # Generate warnings for apk's with no metadata (or create skeleton
1549 # metadata files, if requested on the command line)
1552 if apk['id'] not in apps:
1553 if options.create_metadata:
1554 if 'name' not in apk:
1555 logging.error(apk['id'] + ' does not have a name! Skipping...')
1557 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w', encoding='utf8')
1558 f.write("License:Unknown\n")
1559 f.write("Web Site:\n")
1560 f.write("Source Code:\n")
1561 f.write("Issue Tracker:\n")
1562 f.write("Changelog:\n")
1563 f.write("Summary:" + apk['name'] + "\n")
1564 f.write("Description:\n")
1565 f.write(apk['name'] + "\n")
1567 f.write("Name:" + apk['name'] + "\n")
1569 logging.info("Generated skeleton metadata for " + apk['id'])
1572 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1573 if options.delete_unknown:
1574 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1575 rmf = os.path.join(repodirs[0], apk['apkname'])
1576 if not os.path.exists(rmf):
1577 logging.error("Could not find {0} to remove it".format(rmf))
1581 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1583 # update the metadata with the newly created ones included
1585 apps = metadata.read_metadata()
1587 insert_obbs(repodirs[0], apps, apks)
1589 # Scan the archive repo for apks as well
1590 if len(repodirs) > 1:
1591 archapks, cc = scan_apks(apkcache, repodirs[1], knownapks, options.use_date_from_apk)
1597 # less than the valid range of versionCode, i.e. Java's Integer.MIN_VALUE
1598 UNSET_VERSION_CODE = -0x100000000
1600 # Some information from the apks needs to be applied up to the application
1601 # level. When doing this, we use the info from the most recent version's apk.
1602 # We deal with figuring out when the app was added and last updated at the
1604 for appid, app in apps.items():
1605 bestver = UNSET_VERSION_CODE
1606 for apk in apks + archapks:
1607 if apk['id'] == appid:
1608 if apk['versioncode'] > bestver:
1609 bestver = apk['versioncode']
1613 if not app.added or apk['added'] < app.added:
1614 app.added = apk['added']
1615 if not app.lastupdated or apk['added'] > app.lastupdated:
1616 app.lastupdated = apk['added']
1619 logging.debug("Don't know when " + appid + " was added")
1620 if not app.lastupdated:
1621 logging.debug("Don't know when " + appid + " was last updated")
1623 if bestver == UNSET_VERSION_CODE:
1625 if app.Name is None:
1626 app.Name = app.AutoName or appid
1628 logging.debug("Application " + appid + " has no packages")
1630 if app.Name is None:
1631 app.Name = bestapk['name']
1632 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1633 if app.CurrentVersionCode is None:
1634 app.CurrentVersionCode = str(bestver)
1636 # Sort the app list by name, then the web site doesn't have to by default.
1637 # (we had to wait until we'd scanned the apks to do this, because mostly the
1638 # name comes from there!)
1639 sortedids = sorted(apps.keys(), key=lambda appid: apps[appid].Name.upper())
1641 # APKs are placed into multiple repos based on the app package, providing
1642 # per-app subscription feeds for nightly builds and things like it
1643 if config['per_app_repos']:
1644 add_apks_to_per_app_repos(repodirs[0], apks)
1645 for appid, app in apps.items():
1646 repodir = os.path.join(appid, 'fdroid', 'repo')
1648 appdict[appid] = app
1649 if os.path.isdir(repodir):
1650 make_index(appdict, [appid], apks, repodir, False)
1652 logging.info('Skipping index generation for ' + appid)
1655 if len(repodirs) > 1:
1656 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1658 # Make the index for the main repo...
1659 make_index(apps, sortedids, apks, repodirs[0], False)
1660 make_categories_txt(repodirs[0], categories)
1662 # If there's an archive repo, make the index for it. We already scanned it
1664 if len(repodirs) > 1:
1665 make_index(apps, sortedids, archapks, repodirs[1], True)
1667 if config['update_stats']:
1669 # Update known apks info...
1670 knownapks.writeifchanged()
1672 # Generate latest apps data for widget
1673 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1675 with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
1677 appid = line.rstrip()
1678 data += appid + "\t"
1680 data += app.Name + "\t"
1681 if app.icon is not None:
1682 data += app.icon + "\t"
1683 data += app.License + "\n"
1684 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
1688 apkcache["METADATA_VERSION"] = METADATA_VERSION
1689 with open(apkcachefile, 'wb') as cf:
1690 pickle.dump(apkcache, cf)
1692 # Update the wiki...
1694 update_wiki(apps, sortedids, apks + archapks)
1696 logging.info("Finished.")
1699 if __name__ == "__main__":