2 # -*- coding: utf-8 -*-
4 # update.py - part of the FDroid server tools
5 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU Affero General Public License for more details.
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
31 from datetime import datetime, timedelta
32 from xml.dom.minidom import Document
33 from argparse import ArgumentParser
35 from pyasn1.error import PyAsn1Error
36 from pyasn1.codec.der import decoder, encoder
37 from pyasn1_modules import rfc2315
38 from hashlib import md5
39 from binascii import hexlify, unhexlify
46 from common import FDroidPopen, SdkToolsPopen
47 from metadata import MetaDataException
49 screen_densities = ['640', '480', '320', '240', '160', '120']
51 all_screen_densities = ['0'] + screen_densities
54 def dpi_to_px(density):
55 return (int(density) * 48) / 160
59 return (int(px) * 160) / 48
62 def get_icon_dir(repodir, density):
64 return os.path.join(repodir, "icons")
65 return os.path.join(repodir, "icons-%s" % density)
68 def get_icon_dirs(repodir):
69 for density in screen_densities:
70 yield get_icon_dir(repodir, density)
73 def get_all_icon_dirs(repodir):
74 for density in all_screen_densities:
75 yield get_icon_dir(repodir, density)
78 def update_wiki(apps, sortedids, apks):
81 :param apps: fully populated list of all applications
82 :param apks: all apks, except...
84 logging.info("Updating wiki")
86 wikiredircat = 'App Redirects'
88 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
89 path=config['wiki_path'])
90 site.login(config['wiki_user'], config['wiki_password'])
92 generated_redirects = {}
94 for appid in sortedids:
99 wikidata += '{{Disabled|' + app.Disabled + '}}\n'
101 for af in app.AntiFeatures:
102 wikidata += '{{AntiFeature|' + af + '}}\n'
107 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' % (
110 time.strftime('%Y-%m-%d', app.added) if app.added else '',
111 time.strftime('%Y-%m-%d', app.lastupdated) if app.lastupdated else '',
126 wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
128 wikidata += app.Summary
129 wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
131 wikidata += "=Description=\n"
132 wikidata += metadata.description_wiki(app.Description) + "\n"
134 wikidata += "=Maintainer Notes=\n"
135 if app.MaintainerNotes:
136 wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
137 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)
139 # Get a list of all packages for this application...
141 gotcurrentver = False
145 if apk['id'] == appid:
146 if str(apk['versioncode']) == app.CurrentVersionCode:
149 # Include ones we can't build, as a special case...
150 for build in app.builds:
152 if build.vercode == app.CurrentVersionCode:
154 # TODO: Nasty: vercode is a string in the build, and an int elsewhere
155 apklist.append({'versioncode': int(build.vercode),
156 'version': build.version,
157 'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
162 if apk['versioncode'] == int(build.vercode):
167 apklist.append({'versioncode': int(build.vercode),
168 'version': build.version,
169 'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.vercode),
171 if app.CurrentVersionCode == '0':
173 # Sort with most recent first...
174 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
176 wikidata += "=Versions=\n"
177 if len(apklist) == 0:
178 wikidata += "We currently have no versions of this app available."
179 elif not gotcurrentver:
180 wikidata += "We don't have the current version of this app."
182 wikidata += "We have the current version of this app."
183 wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
184 wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
185 if len(app.NoSourceSince) > 0:
186 wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
187 if len(app.CurrentVersion) > 0:
188 wikidata += "The current (recommended) version is " + app.CurrentVersion
189 wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
192 wikidata += "==" + apk['version'] + "==\n"
194 if 'buildproblem' in apk:
195 wikidata += "We can't build this version: " + apk['buildproblem'] + "\n\n"
198 wikidata += "This version is built and signed by "
200 wikidata += "F-Droid, and guaranteed to correspond to the source tarball published with it.\n\n"
202 wikidata += "the original developer.\n\n"
203 wikidata += "Version code: " + str(apk['versioncode']) + '\n'
205 wikidata += '\n[[Category:' + wikicat + ']]\n'
206 if len(app.NoSourceSince) > 0:
207 wikidata += '\n[[Category:Apps missing source code]]\n'
208 if validapks == 0 and not app.Disabled:
209 wikidata += '\n[[Category:Apps with no packages]]\n'
210 if cantupdate and not app.Disabled:
211 wikidata += "\n[[Category:Apps we cannot update]]\n"
212 if buildfails and not app.Disabled:
213 wikidata += "\n[[Category:Apps with failing builds]]\n"
214 elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
215 wikidata += '\n[[Category:Apps to Update]]\n'
217 wikidata += '\n[[Category:Apps that are disabled]]\n'
218 if app.UpdateCheckMode == 'None' and not app.Disabled:
219 wikidata += '\n[[Category:Apps with no update check]]\n'
220 for appcat in app.Categories:
221 wikidata += '\n[[Category:{0}]]\n'.format(appcat)
223 # We can't have underscores in the page name, even if they're in
224 # the package ID, because MediaWiki messes with them...
225 pagename = appid.replace('_', ' ')
227 # Drop a trailing newline, because mediawiki is going to drop it anyway
228 # and it we don't we'll think the page has changed when it hasn't...
229 if wikidata.endswith('\n'):
230 wikidata = wikidata[:-1]
232 generated_pages[pagename] = wikidata
234 # Make a redirect from the name to the ID too, unless there's
235 # already an existing page with the name and it isn't a redirect.
237 apppagename = app.Name.replace('_', ' ')
238 apppagename = apppagename.replace('{', '')
239 apppagename = apppagename.replace('}', ' ')
240 apppagename = apppagename.replace(':', ' ')
241 # Drop double spaces caused mostly by replacing ':' above
242 apppagename = apppagename.replace(' ', ' ')
243 for expagename in site.allpages(prefix=apppagename,
244 filterredir='nonredirects',
246 if expagename == apppagename:
248 # Another reason not to make the redirect page is if the app name
249 # is the same as it's ID, because that will overwrite the real page
250 # with an redirect to itself! (Although it seems like an odd
251 # scenario this happens a lot, e.g. where there is metadata but no
252 # builds or binaries to extract a name from.
253 if apppagename == pagename:
256 generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
258 for tcat, genp in [(wikicat, generated_pages),
259 (wikiredircat, generated_redirects)]:
260 catpages = site.Pages['Category:' + tcat]
262 for page in catpages:
263 existingpages.append(page.name)
264 if page.name in genp:
265 pagetxt = page.edit()
266 if pagetxt != genp[page.name]:
267 logging.debug("Updating modified page " + page.name)
268 page.save(genp[page.name], summary='Auto-updated')
270 logging.debug("Page " + page.name + " is unchanged")
272 logging.warn("Deleting page " + page.name)
273 page.delete('No longer published')
274 for pagename, text in genp.items():
275 logging.debug("Checking " + pagename)
276 if pagename not in existingpages:
277 logging.debug("Creating page " + pagename)
279 newpage = site.Pages[pagename]
280 newpage.save(text, summary='Auto-created')
282 logging.error("...FAILED to create page '{0}'".format(pagename))
284 # Purge server cache to ensure counts are up to date
285 site.pages['Repository Maintenance'].purge()
288 def delete_disabled_builds(apps, apkcache, repodirs):
289 """Delete disabled build outputs.
291 :param apps: list of all applications, as per metadata.read_metadata
292 :param apkcache: current apk cache information
293 :param repodirs: the repo directories to process
295 for appid, app in apps.iteritems():
296 for build in app.builds:
297 if not build.disable:
299 apkfilename = appid + '_' + str(build.vercode) + '.apk'
300 iconfilename = "%s.%s.png" % (
303 for repodir in repodirs:
305 os.path.join(repodir, apkfilename),
306 os.path.join(repodir, apkfilename + '.asc'),
307 os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
309 for density in all_screen_densities:
310 repo_dir = get_icon_dir(repodir, density)
311 files.append(os.path.join(repo_dir, iconfilename))
314 if os.path.exists(f):
315 logging.info("Deleting disabled build output " + f)
317 if apkfilename in apkcache:
318 del apkcache[apkfilename]
321 def resize_icon(iconpath, density):
323 if not os.path.isfile(iconpath):
327 im = Image.open(iconpath)
328 size = dpi_to_px(density)
330 if any(length > size for length in im.size):
332 im.thumbnail((size, size), Image.ANTIALIAS)
333 logging.debug("%s was too large at %s - new size is %s" % (
334 iconpath, oldsize, im.size))
335 im.save(iconpath, "PNG")
337 except Exception as e:
338 logging.error("Failed resizing {0} - {1}".format(iconpath, e))
341 def resize_all_icons(repodirs):
342 """Resize all icons that exceed the max size
344 :param repodirs: the repo directories to process
346 for repodir in repodirs:
347 for density in screen_densities:
348 icon_dir = get_icon_dir(repodir, density)
349 icon_glob = os.path.join(icon_dir, '*.png')
350 for iconpath in glob.glob(icon_glob):
351 resize_icon(iconpath, density)
354 # A signature block file with a .DSA, .RSA, or .EC extension
355 cert_path_regex = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
359 """ Get the signing certificate of an apk. To get the same md5 has that
360 Android gets, we encode the .RSA certificate in a specific format and pass
361 it hex-encoded to the md5 digest algorithm.
363 :param apkpath: path to the apk
364 :returns: A string containing the md5 of the signature of the apk or None
365 if an error occurred.
370 # verify the jar signature is correct
371 args = [config['jarsigner'], '-verify', apkpath]
372 p = FDroidPopen(args)
373 if p.returncode != 0:
374 logging.critical(apkpath + " has a bad signature!")
377 with zipfile.ZipFile(apkpath, 'r') as apk:
379 certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
382 logging.error("Found no signing certificates on %s" % apkpath)
385 logging.error("Found multiple signing certificates on %s" % apkpath)
388 cert = apk.read(certs[0])
390 content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
391 if content.getComponentByName('contentType') != rfc2315.signedData:
392 logging.error("Unexpected format.")
395 content = decoder.decode(content.getComponentByName('content'),
396 asn1Spec=rfc2315.SignedData())[0]
398 certificates = content.getComponentByName('certificates')
400 logging.error("Certificates not found.")
403 cert_encoded = encoder.encode(certificates)[4:]
405 return md5(cert_encoded.encode('hex')).hexdigest()
408 def scan_apks(apps, apkcache, repodir, knownapks):
409 """Scan the apks in the given repo directory.
411 This also extracts the icons.
413 :param apps: list of all applications, as per metadata.read_metadata
414 :param apkcache: current apk cache information
415 :param repodir: repo directory to scan
416 :param knownapks: known apks info
417 :returns: (apks, cachechanged) where apks is a list of apk information,
418 and cachechanged is True if the apkcache got changed.
423 for icon_dir in get_all_icon_dirs(repodir):
424 if os.path.exists(icon_dir):
426 shutil.rmtree(icon_dir)
427 os.makedirs(icon_dir)
429 os.makedirs(icon_dir)
432 name_pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
433 vercode_pat = re.compile(".*versionCode='([0-9]*)'.*")
434 vername_pat = re.compile(".*versionName='([^']*)'.*")
435 label_pat = re.compile(".*label='(.*?)'(\n| [a-z]*?=).*")
436 icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
437 icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
438 sdkversion_pat = re.compile(".*'([0-9]*)'.*")
439 string_pat = re.compile(".*'([^']*)'.*")
440 for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
442 apkfilename = apkfile[len(repodir) + 1:]
443 if ' ' in apkfilename:
444 logging.critical("Spaces in filenames are not allowed.")
447 # Calculate the sha256...
448 sha = hashlib.sha256()
449 with open(apkfile, 'rb') as f:
455 shasum = sha.hexdigest()
458 if apkfilename in apkcache:
459 apk = apkcache[apkfilename]
460 if apk['sha256'] == shasum:
461 logging.debug("Reading " + apkfilename + " from cache")
464 logging.debug("Ignoring stale cache data for " + apkfilename)
467 logging.debug("Processing " + apkfilename)
469 apk['apkname'] = apkfilename
470 apk['sha256'] = shasum
471 srcfilename = apkfilename[:-4] + "_src.tar.gz"
472 if os.path.exists(os.path.join(repodir, srcfilename)):
473 apk['srcname'] = srcfilename
474 apk['size'] = os.path.getsize(apkfile)
475 apk['permissions'] = set()
476 apk['features'] = set()
477 apk['icons_src'] = {}
479 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
480 if p.returncode != 0:
481 if options.delete_unknown:
482 if os.path.exists(apkfile):
483 logging.error("Failed to get apk information, deleting " + apkfile)
486 logging.error("Could not find {0} to remove it".format(apkfile))
488 logging.error("Failed to get apk information, skipping " + apkfile)
490 for line in p.output.splitlines():
491 if line.startswith("package:"):
493 apk['id'] = re.match(name_pat, line).group(1)
494 apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
495 apk['version'] = re.match(vername_pat, line).group(1)
496 except Exception as e:
497 logging.error("Package matching failed: " + str(e))
498 logging.info("Line was: " + line)
500 elif line.startswith("application:"):
501 apk['name'] = re.match(label_pat, line).group(1)
502 # Keep path to non-dpi icon in case we need it
503 match = re.match(icon_pat_nodpi, line)
505 apk['icons_src']['-1'] = match.group(1)
506 elif line.startswith("launchable-activity:"):
507 # Only use launchable-activity as fallback to application
509 apk['name'] = re.match(label_pat, line).group(1)
510 if '-1' not in apk['icons_src']:
511 match = re.match(icon_pat_nodpi, line)
513 apk['icons_src']['-1'] = match.group(1)
514 elif line.startswith("application-icon-"):
515 match = re.match(icon_pat, line)
517 density = match.group(1)
518 path = match.group(2)
519 apk['icons_src'][density] = path
520 elif line.startswith("sdkVersion:"):
521 m = re.match(sdkversion_pat, line)
523 logging.error(line.replace('sdkVersion:', '')
524 + ' is not a valid minSdkVersion!')
526 apk['sdkversion'] = m.group(1)
527 elif line.startswith("maxSdkVersion:"):
528 apk['maxsdkversion'] = re.match(sdkversion_pat, line).group(1)
529 elif line.startswith("native-code:"):
530 apk['nativecode'] = []
531 for arch in line[13:].split(' '):
532 apk['nativecode'].append(arch[1:-1])
533 elif line.startswith("uses-permission:"):
534 perm = re.match(string_pat, line).group(1)
535 if perm.startswith("android.permission."):
537 apk['permissions'].add(perm)
538 elif line.startswith("uses-feature:"):
539 perm = re.match(string_pat, line).group(1)
540 # Filter out this, it's only added with the latest SDK tools and
541 # causes problems for lots of apps.
542 if perm != "android.hardware.screen.portrait" \
543 and perm != "android.hardware.screen.landscape":
544 if perm.startswith("android.feature."):
546 apk['features'].add(perm)
548 if 'sdkversion' not in apk:
549 logging.warn("No SDK version information found in {0}".format(apkfile))
550 apk['sdkversion'] = 0
552 # Check for debuggable apks...
553 if common.isApkDebuggable(apkfile, config):
554 logging.warn('{0} is set to android:debuggable="true"'.format(apkfile))
556 # Get the signature (or md5 of, to be precise)...
557 logging.debug('Getting signature of {0}'.format(apkfile))
558 apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
560 logging.critical("Failed to get apk signature")
563 apkzip = zipfile.ZipFile(apkfile, 'r')
565 # if an APK has files newer than the system time, suggest updating
566 # the system clock. This is useful for offline systems, used for
567 # signing, which do not have another source of clock sync info. It
568 # has to be more than 24 hours newer because ZIP/APK files do not
569 # store timezone info
570 manifest = apkzip.getinfo('AndroidManifest.xml')
571 if manifest.date_time[1] == 0: # month can't be zero
572 logging.debug('AndroidManifest.xml has no date')
574 dt_obj = datetime(*manifest.date_time)
575 checkdt = dt_obj - timedelta(1)
576 if datetime.today() < checkdt:
577 logging.warn('System clock is older than manifest in: '
579 + '\nSet clock to that time using:\n'
580 + 'sudo date -s "' + str(dt_obj) + '"')
582 iconfilename = "%s.%s.png" % (
586 # Extract the icon file...
588 for density in screen_densities:
589 if density not in apk['icons_src']:
590 empty_densities.append(density)
592 iconsrc = apk['icons_src'][density]
593 icon_dir = get_icon_dir(repodir, density)
594 icondest = os.path.join(icon_dir, iconfilename)
597 with open(icondest, 'wb') as f:
598 f.write(apkzip.read(iconsrc))
599 apk['icons'][density] = iconfilename
602 logging.warn("Error retrieving icon file")
603 del apk['icons'][density]
604 del apk['icons_src'][density]
605 empty_densities.append(density)
607 if '-1' in apk['icons_src']:
608 iconsrc = apk['icons_src']['-1']
609 iconpath = os.path.join(
610 get_icon_dir(repodir, '0'), iconfilename)
611 with open(iconpath, 'wb') as f:
612 f.write(apkzip.read(iconsrc))
614 im = Image.open(iconpath)
615 dpi = px_to_dpi(im.size[0])
616 for density in screen_densities:
617 if density in apk['icons']:
619 if density == screen_densities[-1] or dpi >= int(density):
620 apk['icons'][density] = iconfilename
621 shutil.move(iconpath,
622 os.path.join(get_icon_dir(repodir, density), iconfilename))
623 empty_densities.remove(density)
625 except Exception as e:
626 logging.warn("Failed reading {0} - {1}".format(iconpath, e))
629 apk['icon'] = iconfilename
633 # First try resizing down to not lose quality
635 for density in screen_densities:
636 if density not in empty_densities:
637 last_density = density
639 if last_density is None:
641 logging.debug("Density %s not available, resizing down from %s"
642 % (density, last_density))
644 last_iconpath = os.path.join(
645 get_icon_dir(repodir, last_density), iconfilename)
646 iconpath = os.path.join(
647 get_icon_dir(repodir, density), iconfilename)
649 im = Image.open(last_iconpath)
651 logging.warn("Invalid image file at %s" % last_iconpath)
654 size = dpi_to_px(density)
656 im.thumbnail((size, size), Image.ANTIALIAS)
657 im.save(iconpath, "PNG")
658 empty_densities.remove(density)
660 # Then just copy from the highest resolution available
662 for density in reversed(screen_densities):
663 if density not in empty_densities:
664 last_density = density
666 if last_density is None:
668 logging.debug("Density %s not available, copying from lower density %s"
669 % (density, last_density))
672 os.path.join(get_icon_dir(repodir, last_density), iconfilename),
673 os.path.join(get_icon_dir(repodir, density), iconfilename))
675 empty_densities.remove(density)
677 for density in screen_densities:
678 icon_dir = get_icon_dir(repodir, density)
679 icondest = os.path.join(icon_dir, iconfilename)
680 resize_icon(icondest, density)
682 # Copy from icons-mdpi to icons since mdpi is the baseline density
683 baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
684 if os.path.isfile(baseline):
685 apk['icons']['0'] = iconfilename
686 shutil.copyfile(baseline,
687 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
689 # Record in known apks, getting the added date at the same time..
690 added = knownapks.recordapk(apk['apkname'], apk['id'])
694 apkcache[apkfilename] = apk
699 return apks, cachechanged
702 repo_pubkey_fingerprint = None
705 # Generate a certificate fingerprint the same way keytool does it
706 # (but with slightly different formatting)
707 def cert_fingerprint(data):
708 digest = hashlib.sha256(data).digest()
710 ret.append(' '.join("%02X" % ord(b) for b in digest))
714 def extract_pubkey():
715 global repo_pubkey_fingerprint
716 if 'repo_pubkey' in config:
717 pubkey = unhexlify(config['repo_pubkey'])
719 p = FDroidPopen([config['keytool'], '-exportcert',
720 '-alias', config['repo_keyalias'],
721 '-keystore', config['keystore'],
722 '-storepass:file', config['keystorepassfile']]
723 + config['smartcardoptions'], output=False)
724 if p.returncode != 0 or len(p.output) < 20:
725 msg = "Failed to get repo pubkey!"
726 if config['keystore'] == 'NONE':
727 msg += ' Is your crypto smartcard plugged in?'
728 logging.critical(msg)
731 repo_pubkey_fingerprint = cert_fingerprint(pubkey)
732 return hexlify(pubkey)
735 def make_index(apps, sortedids, apks, repodir, archive, categories):
736 """Make a repo index.
738 :param apps: fully populated apps list
739 :param apks: full populated apks list
740 :param repodir: the repo directory
741 :param archive: True if this is the archive repo, False if it's the
743 :param categories: list of categories
748 def addElement(name, value, doc, parent):
749 el = doc.createElement(name)
750 el.appendChild(doc.createTextNode(value))
751 parent.appendChild(el)
753 def addElementNonEmpty(name, value, doc, parent):
756 addElement(name, value, doc, parent)
758 def addElementCDATA(name, value, doc, parent):
759 el = doc.createElement(name)
760 el.appendChild(doc.createCDATASection(value))
761 parent.appendChild(el)
763 root = doc.createElement("fdroid")
764 doc.appendChild(root)
766 repoel = doc.createElement("repo")
768 mirrorcheckfailed = False
769 for mirror in config.get('mirrors', []):
770 base = os.path.basename(urlparse.urlparse(mirror).path.rstrip('/'))
771 if config.get('nonstandardwebroot') is not True and base != 'fdroid':
772 logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
773 mirrorcheckfailed = True
774 if mirrorcheckfailed:
778 repoel.setAttribute("name", config['archive_name'])
779 if config['repo_maxage'] != 0:
780 repoel.setAttribute("maxage", str(config['repo_maxage']))
781 repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
782 repoel.setAttribute("url", config['archive_url'])
783 addElement('description', config['archive_description'], doc, repoel)
784 urlbasepath = os.path.basename(urlparse.urlparse(config['archive_url']).path)
785 for mirror in config.get('mirrors', []):
786 addElement('mirror', urlparse.urljoin(mirror, urlbasepath), doc, repoel)
789 repoel.setAttribute("name", config['repo_name'])
790 if config['repo_maxage'] != 0:
791 repoel.setAttribute("maxage", str(config['repo_maxage']))
792 repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
793 repoel.setAttribute("url", config['repo_url'])
794 addElement('description', config['repo_description'], doc, repoel)
795 urlbasepath = os.path.basename(urlparse.urlparse(config['repo_url']).path)
796 for mirror in config.get('mirrors', []):
797 addElement('mirror', urlparse.urljoin(mirror, urlbasepath), doc, repoel)
799 repoel.setAttribute("version", "15")
800 repoel.setAttribute("timestamp", str(int(time.time())))
803 if not options.nosign:
804 if 'repo_keyalias' not in config:
806 logging.critical("'repo_keyalias' not found in config.py!")
807 if 'keystore' not in config:
809 logging.critical("'keystore' not found in config.py!")
810 if 'keystorepass' not in config and 'keystorepassfile' not in config:
812 logging.critical("'keystorepass' not found in config.py!")
813 if 'keypass' not in config and 'keypassfile' not in config:
815 logging.critical("'keypass' not found in config.py!")
816 if not os.path.exists(config['keystore']):
818 logging.critical("'" + config['keystore'] + "' does not exist!")
820 logging.warning("`fdroid update` requires a signing key, you can create one using:")
821 logging.warning("\tfdroid update --create-key")
824 repoel.setAttribute("pubkey", extract_pubkey())
825 root.appendChild(repoel)
827 for appid in sortedids:
830 if app.Disabled is not None:
833 # Get a list of the apks for this app...
836 if apk['id'] == appid:
839 if len(apklist) == 0:
842 apel = doc.createElement("application")
843 apel.setAttribute("id", app.id)
844 root.appendChild(apel)
846 addElement('id', app.id, doc, apel)
848 addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
850 addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
851 addElement('name', app.Name, doc, apel)
852 addElement('summary', app.Summary, doc, apel)
854 addElement('icon', app.icon, doc, apel)
858 return ("fdroid.app:" + appid, apps[appid].Name)
859 raise MetaDataException("Cannot resolve app id " + appid)
862 metadata.description_html(app.Description, linkres),
864 addElement('license', app.License, doc, apel)
866 addElement('categories', ','.join(app.Categories), doc, apel)
867 # We put the first (primary) category in LAST, which will have
868 # the desired effect of making clients that only understand one
869 # category see that one.
870 addElement('category', app.Categories[0], doc, apel)
871 addElement('web', app.WebSite, doc, apel)
872 addElement('source', app.SourceCode, doc, apel)
873 addElement('tracker', app.IssueTracker, doc, apel)
874 addElementNonEmpty('changelog', app.Changelog, doc, apel)
875 addElementNonEmpty('author', app.AuthorName, doc, apel)
876 addElementNonEmpty('email', app.AuthorEmail, doc, apel)
877 addElementNonEmpty('donate', app.Donate, doc, apel)
878 addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
879 addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
880 addElementNonEmpty('flattr', app.FlattrID, doc, apel)
882 # These elements actually refer to the current version (i.e. which
883 # one is recommended. They are historically mis-named, and need
884 # changing, but stay like this for now to support existing clients.
885 addElement('marketversion', app.CurrentVersion, doc, apel)
886 addElement('marketvercode', app.CurrentVersionCode, doc, apel)
889 af = app.AntiFeatures
891 addElementNonEmpty('antifeatures', ','.join(af), doc, apel)
893 pv = app.Provides.split(',')
894 addElementNonEmpty('provides', ','.join(pv), doc, apel)
896 addElement('requirements', 'root', doc, apel)
898 # Sort the apk list into version order, just so the web site
899 # doesn't have to do any work by default...
900 apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
902 # Check for duplicates - they will make the client unhappy...
903 for i in range(len(apklist) - 1):
904 if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
905 logging.critical("duplicate versions: '%s' - '%s'" % (
906 apklist[i]['apkname'], apklist[i + 1]['apkname']))
909 current_version_code = 0
910 current_version_file = None
912 # find the APK for the "Current Version"
913 if current_version_code < apk['versioncode']:
914 current_version_code = apk['versioncode']
915 if current_version_code < int(app.CurrentVersionCode):
916 current_version_file = apk['apkname']
918 apkel = doc.createElement("package")
919 apel.appendChild(apkel)
920 addElement('version', apk['version'], doc, apkel)
921 addElement('versioncode', str(apk['versioncode']), doc, apkel)
922 addElement('apkname', apk['apkname'], doc, apkel)
924 addElement('srcname', apk['srcname'], doc, apkel)
925 for hash_type in ['sha256']:
926 if hash_type not in apk:
928 hashel = doc.createElement("hash")
929 hashel.setAttribute("type", hash_type)
930 hashel.appendChild(doc.createTextNode(apk[hash_type]))
931 apkel.appendChild(hashel)
932 addElement('sig', apk['sig'], doc, apkel)
933 addElement('size', str(apk['size']), doc, apkel)
934 addElement('sdkver', str(apk['sdkversion']), doc, apkel)
935 if 'maxsdkversion' in apk:
936 addElement('maxsdkver', str(apk['maxsdkversion']), doc, apkel)
938 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
939 addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
940 if 'nativecode' in apk:
941 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
942 addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
944 if current_version_file is not None \
945 and config['make_current_version_link'] \
946 and repodir == 'repo': # only create these
947 namefield = config['current_version_name_source']
948 sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get_field(namefield))
949 apklinkname = sanitized_name + '.apk'
950 current_version_path = os.path.join(repodir, current_version_file)
951 if os.path.islink(apklinkname):
952 os.remove(apklinkname)
953 os.symlink(current_version_path, apklinkname)
954 # also symlink gpg signature, if it exists
955 for extension in ('.asc', '.sig'):
956 sigfile_path = current_version_path + extension
957 if os.path.exists(sigfile_path):
958 siglinkname = apklinkname + extension
959 if os.path.islink(siglinkname):
960 os.remove(siglinkname)
961 os.symlink(sigfile_path, siglinkname)
964 output = doc.toprettyxml()
968 with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
971 if 'repo_keyalias' in config:
974 logging.info("Creating unsigned index in preparation for signing")
976 logging.info("Creating signed index with this key (SHA256):")
977 logging.info("%s" % repo_pubkey_fingerprint)
979 # Create a jar of the index...
980 jar_output = 'index_unsigned.jar' if options.nosign else 'index.jar'
981 p = FDroidPopen(['jar', 'cf', jar_output, 'index.xml'], cwd=repodir)
982 if p.returncode != 0:
983 logging.critical("Failed to create {0}".format(jar_output))
987 signed = os.path.join(repodir, 'index.jar')
989 # Remove old signed index if not signing
990 if os.path.exists(signed):
993 args = [config['jarsigner'], '-keystore', config['keystore'],
994 '-storepass:file', config['keystorepassfile'],
995 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
996 signed, config['repo_keyalias']]
997 if config['keystore'] == 'NONE':
998 args += config['smartcardoptions']
999 else: # smardcards never use -keypass
1000 args += ['-keypass:file', config['keypassfile']]
1001 p = FDroidPopen(args)
1002 if p.returncode != 0:
1003 logging.critical("Failed to sign index")
1006 # Copy the repo icon into the repo directory...
1007 icon_dir = os.path.join(repodir, 'icons')
1008 iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
1009 shutil.copyfile(config['repo_icon'], iconfilename)
1011 # Write a category list in the repo to allow quick access...
1013 for cat in categories:
1014 catdata += cat + '\n'
1015 with open(os.path.join(repodir, 'categories.txt'), 'w') as f:
1019 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
1021 for appid, app in apps.iteritems():
1023 if app.ArchivePolicy:
1024 keepversions = int(app.ArchivePolicy[:-9])
1026 keepversions = defaultkeepversions
1028 def filter_apk_list_sorted(apk_list):
1030 for apk in apk_list:
1031 if apk['id'] == appid:
1034 # Sort the apk list by version code. First is highest/newest.
1035 return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
1037 def move_file(from_dir, to_dir, filename, ignore_missing):
1038 from_path = os.path.join(from_dir, filename)
1039 if ignore_missing and not os.path.exists(from_path):
1041 to_path = os.path.join(to_dir, filename)
1042 shutil.move(from_path, to_path)
1044 if len(apks) > keepversions:
1045 apklist = filter_apk_list_sorted(apks)
1046 # Move back the ones we don't want.
1047 for apk in apklist[keepversions:]:
1048 logging.info("Moving " + apk['apkname'] + " to archive")
1049 move_file(repodir, archivedir, apk['apkname'], False)
1050 move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
1051 for density in all_screen_densities:
1052 repo_icon_dir = get_icon_dir(repodir, density)
1053 archive_icon_dir = get_icon_dir(archivedir, density)
1054 if density not in apk['icons']:
1056 move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
1057 if 'srcname' in apk:
1058 move_file(repodir, archivedir, apk['srcname'], False)
1059 archapks.append(apk)
1061 elif len(apks) < keepversions and len(archapks) > 0:
1062 required = keepversions - len(apks)
1063 archapklist = filter_apk_list_sorted(archapks)
1064 # Move forward the ones we want again.
1065 for apk in archapklist[:required]:
1066 logging.info("Moving " + apk['apkname'] + " from archive")
1067 move_file(archivedir, repodir, apk['apkname'], False)
1068 move_file(archivedir, repodir, apk['apkname'] + '.asc', True)
1069 for density in all_screen_densities:
1070 repo_icon_dir = get_icon_dir(repodir, density)
1071 archive_icon_dir = get_icon_dir(archivedir, density)
1072 if density not in apk['icons']:
1074 move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
1075 if 'srcname' in apk:
1076 move_file(archivedir, repodir, apk['srcname'], False)
1077 archapks.remove(apk)
1081 def add_apks_to_per_app_repos(repodir, apks):
1082 apks_per_app = dict()
1084 apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
1085 apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
1086 apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
1087 apks_per_app[apk['id']] = apk
1089 if not os.path.exists(apk['per_app_icons']):
1090 logging.info('Adding new repo for only ' + apk['id'])
1091 os.makedirs(apk['per_app_icons'])
1093 apkpath = os.path.join(repodir, apk['apkname'])
1094 shutil.copy(apkpath, apk['per_app_repo'])
1095 apksigpath = apkpath + '.sig'
1096 if os.path.exists(apksigpath):
1097 shutil.copy(apksigpath, apk['per_app_repo'])
1098 apkascpath = apkpath + '.asc'
1099 if os.path.exists(apkascpath):
1100 shutil.copy(apkascpath, apk['per_app_repo'])
1109 global config, options
1111 # Parse command line...
1112 parser = ArgumentParser()
1113 common.setup_global_opts(parser)
1114 parser.add_argument("--create-key", action="store_true", default=False,
1115 help="Create a repo signing key in a keystore")
1116 parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
1117 help="Create skeleton metadata files that are missing")
1118 parser.add_argument("--delete-unknown", action="store_true", default=False,
1119 help="Delete APKs without metadata from the repo")
1120 parser.add_argument("-b", "--buildreport", action="store_true", default=False,
1121 help="Report on build data status")
1122 parser.add_argument("-i", "--interactive", default=False, action="store_true",
1123 help="Interactively ask about things that need updating.")
1124 parser.add_argument("-I", "--icons", action="store_true", default=False,
1125 help="Resize all the icons exceeding the max pixel size and exit")
1126 parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
1127 help="Specify editor to use in interactive mode. Default " +
1128 "is /etc/alternatives/editor")
1129 parser.add_argument("-w", "--wiki", default=False, action="store_true",
1130 help="Update the wiki")
1131 parser.add_argument("--pretty", action="store_true", default=False,
1132 help="Produce human-readable index.xml")
1133 parser.add_argument("--clean", action="store_true", default=False,
1134 help="Clean update - don't uses caches, reprocess all apks")
1135 parser.add_argument("--nosign", action="store_true", default=False,
1136 help="When configured for signed indexes, create only unsigned indexes at this stage")
1137 options = parser.parse_args()
1139 config = common.read_config(options)
1141 if not ('jarsigner' in config and 'keytool' in config):
1142 logging.critical('Java JDK not found! Install in standard location or set java_paths!')
1146 if config['archive_older'] != 0:
1147 repodirs.append('archive')
1148 if not os.path.exists('archive'):
1152 resize_all_icons(repodirs)
1155 # check that icons exist now, rather than fail at the end of `fdroid update`
1156 for k in ['repo_icon', 'archive_icon']:
1158 if not os.path.exists(config[k]):
1159 logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
1162 # if the user asks to create a keystore, do it now, reusing whatever it can
1163 if options.create_key:
1164 if os.path.exists(config['keystore']):
1165 logging.critical("Cowardily refusing to overwrite existing signing key setup!")
1166 logging.critical("\t'" + config['keystore'] + "'")
1169 if 'repo_keyalias' not in config:
1170 config['repo_keyalias'] = socket.getfqdn()
1171 common.write_to_config(config, 'repo_keyalias', config['repo_keyalias'])
1172 if 'keydname' not in config:
1173 config['keydname'] = 'CN=' + config['repo_keyalias'] + ', OU=F-Droid'
1174 common.write_to_config(config, 'keydname', config['keydname'])
1175 if 'keystore' not in config:
1176 config['keystore'] = common.default_config.keystore
1177 common.write_to_config(config, 'keystore', config['keystore'])
1179 password = common.genpassword()
1180 if 'keystorepass' not in config:
1181 config['keystorepass'] = password
1182 common.write_to_config(config, 'keystorepass', config['keystorepass'])
1183 if 'keypass' not in config:
1184 config['keypass'] = password
1185 common.write_to_config(config, 'keypass', config['keypass'])
1186 common.genkeystore(config)
1189 apps = metadata.read_metadata()
1191 # Generate a list of categories...
1193 for app in apps.itervalues():
1194 categories.update(app.Categories)
1196 # Read known apks data (will be updated and written back when we've finished)
1197 knownapks = common.KnownApks()
1199 # Gather information about all the apk files in the repo directory, using
1200 # cached data if possible.
1201 apkcachefile = os.path.join('tmp', 'apkcache')
1202 if not options.clean and os.path.exists(apkcachefile):
1203 with open(apkcachefile, 'rb') as cf:
1204 apkcache = pickle.load(cf)
1208 delete_disabled_builds(apps, apkcache, repodirs)
1210 # Scan all apks in the main repo
1211 apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks)
1213 # Generate warnings for apk's with no metadata (or create skeleton
1214 # metadata files, if requested on the command line)
1217 if apk['id'] not in apps:
1218 if options.create_metadata:
1219 if 'name' not in apk:
1220 logging.error(apk['id'] + ' does not have a name! Skipping...')
1222 f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w')
1223 f.write("License:Unknown\n")
1224 f.write("Web Site:\n")
1225 f.write("Source Code:\n")
1226 f.write("Issue Tracker:\n")
1227 f.write("Changelog:\n")
1228 f.write("Summary:" + apk['name'] + "\n")
1229 f.write("Description:\n")
1230 f.write(apk['name'] + "\n")
1233 logging.info("Generated skeleton metadata for " + apk['id'])
1236 msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
1237 if options.delete_unknown:
1238 logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
1239 rmf = os.path.join(repodirs[0], apk['apkname'])
1240 if not os.path.exists(rmf):
1241 logging.error("Could not find {0} to remove it".format(rmf))
1245 logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
1247 # update the metadata with the newly created ones included
1249 apps = metadata.read_metadata()
1251 # Scan the archive repo for apks as well
1252 if len(repodirs) > 1:
1253 archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks)
1259 # Some information from the apks needs to be applied up to the application
1260 # level. When doing this, we use the info from the most recent version's apk.
1261 # We deal with figuring out when the app was added and last updated at the
1263 for appid, app in apps.iteritems():
1265 for apk in apks + archapks:
1266 if apk['id'] == appid:
1267 if apk['versioncode'] > bestver:
1268 bestver = apk['versioncode']
1272 if not app.added or apk['added'] < app.added:
1273 app.added = apk['added']
1274 if not app.lastupdated or apk['added'] > app.lastupdated:
1275 app.lastupdated = apk['added']
1278 logging.debug("Don't know when " + appid + " was added")
1279 if not app.lastupdated:
1280 logging.debug("Don't know when " + appid + " was last updated")
1283 if app.Name is None:
1284 app.Name = app.AutoName or appid
1286 logging.debug("Application " + appid + " has no packages")
1288 if app.Name is None:
1289 app.Name = bestapk['name']
1290 app.icon = bestapk['icon'] if 'icon' in bestapk else None
1291 if app.CurrentVersionCode is None:
1292 app.CurrentVersionCode = str(bestver)
1294 # Sort the app list by name, then the web site doesn't have to by default.
1295 # (we had to wait until we'd scanned the apks to do this, because mostly the
1296 # name comes from there!)
1297 sortedids = sorted(apps.iterkeys(), key=lambda appid: apps[appid].Name.upper())
1299 # APKs are placed into multiple repos based on the app package, providing
1300 # per-app subscription feeds for nightly builds and things like it
1301 if config['per_app_repos']:
1302 add_apks_to_per_app_repos(repodirs[0], apks)
1303 for appid, app in apps.iteritems():
1304 repodir = os.path.join(appid, 'fdroid', 'repo')
1306 appdict[appid] = app
1307 if os.path.isdir(repodir):
1308 make_index(appdict, [appid], apks, repodir, False, categories)
1310 logging.info('Skipping index generation for ' + appid)
1313 if len(repodirs) > 1:
1314 archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
1316 # Make the index for the main repo...
1317 make_index(apps, sortedids, apks, repodirs[0], False, categories)
1319 # If there's an archive repo, make the index for it. We already scanned it
1321 if len(repodirs) > 1:
1322 make_index(apps, sortedids, archapks, repodirs[1], True, categories)
1324 if config['update_stats']:
1326 # Update known apks info...
1327 knownapks.writeifchanged()
1329 # Generate latest apps data for widget
1330 if os.path.exists(os.path.join('stats', 'latestapps.txt')):
1332 for line in file(os.path.join('stats', 'latestapps.txt')):
1333 appid = line.rstrip()
1334 data += appid + "\t"
1336 data += app.Name + "\t"
1337 if app.icon is not None:
1338 data += app.icon + "\t"
1339 data += app.License + "\n"
1340 with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w') as f:
1344 with open(apkcachefile, 'wb') as cf:
1345 pickle.dump(apkcache, cf)
1347 # Update the wiki...
1349 update_wiki(apps, sortedids, apks + archapks)
1351 logging.info("Finished.")
1353 if __name__ == "__main__":