chiark / gitweb /
Replace getsig.java with a pure python implementation
[fdroidserver.git] / fdroidserver / update.py
index dd2f8728e3ab939b088a268a065ab404a262e432..c618fc787ed5c693808e04e050defd24c9b2b2f5 100644 (file)
@@ -3,7 +3,7 @@
 #
 # update.py - part of the FDroid server tools
 # Copyright (C) 2010-2013, Ciaran Gultnieks, ciaran@ciarang.com
-# Copyright (C) 2013 Daniel Martí <mvdan@mvdan.cc>
+# Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -22,7 +22,6 @@ import sys
 import os
 import shutil
 import glob
-import subprocess
 import re
 import zipfile
 import hashlib
@@ -30,73 +29,90 @@ import pickle
 from xml.dom.minidom import Document
 from optparse import OptionParser
 import time
-import common, metadata
-from metadata import MetaDataException
+from pyasn1.error import PyAsn1Error
+from pyasn1.codec.der import decoder, encoder
+from pyasn1_modules import rfc2315
+from hashlib import md5
+
 from PIL import Image
+import logging
+
+import common
+import metadata
+from common import FDroidPopen, SilentPopen
+from metadata import MetaDataException
 
 
 def get_densities():
     return ['640', '480', '320', '240', '160', '120']
 
+
 def dpi_to_px(density):
     return (int(density) * 48) / 160
 
+
 def px_to_dpi(px):
     return (int(px) * 160) / 48
 
+
 def get_icon_dir(repodir, density):
     if density is None:
         return os.path.join(repodir, "icons")
     return os.path.join(repodir, "icons-%s" % density)
 
+
 def get_icon_dirs(repodir):
     for density in get_densities():
         yield get_icon_dir(repodir, density)
     yield os.path.join(repodir, "icons")
 
-def update_wiki(apps, apks):
+
+def update_wiki(apps, sortedids, apks):
     """Update the wiki
 
     :param apps: fully populated list of all applications
     :param apks: all apks, except...
     """
-    print "Updating wiki"
+    logging.info("Updating wiki")
     wikicat = 'Apps'
     wikiredircat = 'App Redirects'
     import mwclient
     site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
-            path=config['wiki_path'])
+                         path=config['wiki_path'])
     site.login(config['wiki_user'], config['wiki_password'])
     generated_pages = {}
     generated_redirects = {}
-    for app in apps:
+
+    for appid in sortedids:
+        app = apps[appid]
+
         wikidata = ''
         if app['Disabled']:
             wikidata += '{{Disabled|' + app['Disabled'] + '}}\n'
         if app['AntiFeatures']:
             for af in app['AntiFeatures'].split(','):
                 wikidata += '{{AntiFeature|' + af + '}}\n'
-        wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|dogecoin=%s|license=%s|root=%s}}\n'%(
-                app['id'],
-                app['Name'],
-                time.strftime('%Y-%m-%d', app['added']) if 'added' in app else '',
-                time.strftime('%Y-%m-%d', app['lastupdated']) if 'lastupdated' in app else '',
-                app['Source Code'],
-                app['Issue Tracker'],
-                app['Web Site'],
-                app['Donate'],
-                app['FlattrID'],
-                app['Bitcoin'],
-                app['Litecoin'],
-                app['Dogecoin'],
-                app['License'],
-                app.get('Requires Root', 'No'))
+        wikidata += '{{App|id=%s|name=%s|added=%s|lastupdated=%s|source=%s|tracker=%s|web=%s|donate=%s|flattr=%s|bitcoin=%s|litecoin=%s|dogecoin=%s|license=%s|root=%s}}\n' % (
+            appid,
+            app['Name'],
+            time.strftime('%Y-%m-%d', app['added']) if 'added' in app else '',
+            time.strftime('%Y-%m-%d', app['lastupdated']) if 'lastupdated' in app else '',
+            app['Source Code'],
+            app['Issue Tracker'],
+            app['Web Site'],
+            app['Donate'],
+            app['FlattrID'],
+            app['Bitcoin'],
+            app['Litecoin'],
+            app['Dogecoin'],
+            app['License'],
+            app.get('Requires Root', 'No'))
 
         if app['Provides']:
             wikidata += "This app provides: %s" % ', '.join(app['Summary'].split(','))
 
         wikidata += app['Summary']
-        wikidata += " - [http://f-droid.org/repository/browse/?fdid=" + app['id'] + " view in repository]\n\n"
+        wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
 
         wikidata += "=Description=\n"
         wikidata += metadata.description_wiki(app['Description']) + "\n"
@@ -104,7 +120,7 @@ def update_wiki(apps, apks):
         wikidata += "=Maintainer Notes=\n"
         if 'Maintainer Notes' in app:
             wikidata += metadata.description_wiki(app['Maintainer Notes']) + "\n"
-        wikidata += "\nMetadata: [https://gitorious.org/f-droid/fdroiddata/source/master:metadata/{0}.txt current] [https://gitorious.org/f-droid/fdroiddata/history/metadata/{0}.txt history]\n".format(app['id'])
+        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)
 
         # Get a list of all packages for this application...
         apklist = []
@@ -112,21 +128,20 @@ def update_wiki(apps, apks):
         cantupdate = False
         buildfails = False
         for apk in apks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 if str(apk['versioncode']) == app['Current Version Code']:
                     gotcurrentver = True
                 apklist.append(apk)
         # Include ones we can't build, as a special case...
         for thisbuild in app['builds']:
-            if 'disable' in thisbuild:
+            if thisbuild['disable']:
                 if thisbuild['vercode'] == app['Current Version Code']:
                     cantupdate = True
-                apklist.append({
-                        #TODO: Nasty: vercode is a string in the build, and an int elsewhere
-                        'versioncode': int(thisbuild['vercode']),
-                        'version': thisbuild['version'],
-                        'buildproblem': thisbuild['disable']
-                    })
+                # TODO: Nasty: vercode is a string in the build, and an int elsewhere
+                apklist.append({'versioncode': int(thisbuild['vercode']),
+                                'version': thisbuild['version'],
+                                'buildproblem': thisbuild['disable']
+                                })
             else:
                 builtit = False
                 for apk in apklist:
@@ -135,11 +150,12 @@ def update_wiki(apps, apks):
                         break
                 if not builtit:
                     buildfails = True
-                    apklist.append({
-                            'versioncode': int(thisbuild['vercode']),
-                            'version': thisbuild['version'],
-                            'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild|build log]].".format(app['id'])
-                        })
+                    apklist.append({'versioncode': int(thisbuild['vercode']),
+                                    'version': thisbuild['version'],
+                                    'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, thisbuild['vercode'])
+                                    })
+        if app['Current Version Code'] == '0':
+            cantupdate = True
         # Sort with most recent first...
         apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
 
@@ -175,24 +191,24 @@ def update_wiki(apps, apks):
         wikidata += '\n[[Category:' + wikicat + ']]\n'
         if len(app['No Source Since']) > 0:
             wikidata += '\n[[Category:Apps missing source code]]\n'
-        elif validapks == 0 and not app['Disabled']:
+        if validapks == 0 and not app['Disabled']:
             wikidata += '\n[[Category:Apps with no packages]]\n'
-        elif cantupdate and not app['Disabled']:
+        if cantupdate and not app['Disabled']:
             wikidata += "\n[[Category:Apps we can't update]]\n"
-        elif buildfails and not app['Disabled']:
+        if buildfails and not app['Disabled']:
             wikidata += "\n[[Category:Apps with failing builds]]\n"
-        elif not gotcurrentver and not app['Disabled'] and app['Update Check Mode'] != "Static":
+        elif not gotcurrentver and not cantupdate and not app['Disabled'] and app['Update Check Mode'] != "Static":
             wikidata += '\n[[Category:Apps to Update]]\n'
         if app['Disabled']:
             wikidata += '\n[[Category:Apps that are disabled]]\n'
         if app['Update Check Mode'] == 'None' and not app['Disabled']:
             wikidata += '\n[[Category:Apps with no update check]]\n'
-        for appcat in [c.strip() for c in app['Categories'].split(',')]:
+        for appcat in app['Categories']:
             wikidata += '\n[[Category:{0}]]\n'.format(appcat)
 
         # We can't have underscores in the page name, even if they're in
         # the package ID, because MediaWiki messes with them...
-        pagename = app['id'].replace('_', ' ')
+        pagename = appid.replace('_', ' ')
 
         # Drop a trailing newline, because mediawiki is going to drop it anyway
         # and it we don't we'll think the page has changed when it hasn't...
@@ -211,7 +227,8 @@ def update_wiki(apps, apks):
         # Drop double spaces caused mostly by replacing ':' above
         apppagename = apppagename.replace('  ', ' ')
         for expagename in site.allpages(prefix=apppagename,
-                filterredir='nonredirects', generator=False):
+                                        filterredir='nonredirects',
+                                        generator=False):
             if expagename == apppagename:
                 noclobber = True
         # Another reason not to make the redirect page is if the app name
@@ -225,7 +242,7 @@ def update_wiki(apps, apks):
             generated_redirects[apppagename] = "#REDIRECT [[" + pagename + "]]\n[[Category:" + wikiredircat + "]]"
 
     for tcat, genp in [(wikicat, generated_pages),
-            (wikiredircat, generated_redirects)]:
+                       (wikiredircat, generated_redirects)]:
         catpages = site.Pages['Category:' + tcat]
         existingpages = []
         for page in catpages:
@@ -233,28 +250,27 @@ def update_wiki(apps, apks):
             if page.name in genp:
                 pagetxt = page.edit()
                 if pagetxt != genp[page.name]:
-                    print "Updating modified page " + page.name
+                    logging.debug("Updating modified page " + page.name)
                     page.save(genp[page.name], summary='Auto-updated')
                 else:
-                    if options.verbose:
-                        print "Page " + page.name + " is unchanged"
+                    logging.debug("Page " + page.name + " is unchanged")
             else:
-                print "Deleting page " + page.name
+                logging.warn("Deleting page " + page.name)
                 page.delete('No longer published')
         for pagename, text in genp.items():
-            if options.verbose:
-                print "Checking " + pagename
-            if not pagename in existingpages:
-                print "Creating page " + pagename
+            logging.debug("Checking " + pagename)
+            if pagename not in existingpages:
+                logging.debug("Creating page " + pagename)
                 try:
                     newpage = site.Pages[pagename]
                     newpage.save(text, summary='Auto-created')
                 except:
-                    print "...FAILED to create page"
+                    logging.error("...FAILED to create page")
 
     # Purge server cache to ensure counts are up to date
     site.pages['Repository Maintenance'].purge()
 
+
 def delete_disabled_builds(apps, apkcache, repodirs):
     """Delete disabled build outputs.
 
@@ -262,22 +278,27 @@ def delete_disabled_builds(apps, apkcache, repodirs):
     :param apkcache: current apk cache information
     :param repodirs: the repo directories to process
     """
-    for app in apps:
+    for appid, app in apps.iteritems():
         for build in app['builds']:
-            if 'disable' in build:
-                apkfilename = app['id'] + '_' + str(build['vercode']) + '.apk'
+            if build['disable']:
+                apkfilename = appid + '_' + str(build['vercode']) + '.apk'
                 for repodir in repodirs:
                     apkpath = os.path.join(repodir, apkfilename)
+                    ascpath = apkpath + ".asc"
                     srcpath = os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz")
-                    for name in [apkpath, srcpath]:
+                    for name in [apkpath, srcpath, ascpath]:
                         if os.path.exists(name):
-                            print "Deleting disabled build output " + apkfilename
+                            logging.warn("Deleting disabled build output " + apkfilename)
                             os.remove(name)
                 if apkfilename in apkcache:
                     del apkcache[apkfilename]
 
+
 def resize_icon(iconpath, density):
 
+    if not os.path.isfile(iconpath):
+        return
+
     try:
         im = Image.open(iconpath)
         size = dpi_to_px(density)
@@ -285,15 +306,13 @@ def resize_icon(iconpath, density):
         if any(length > size for length in im.size):
             oldsize = im.size
             im.thumbnail((size, size), Image.ANTIALIAS)
-            print iconpath, "was too large at", oldsize, "- new size is", im.size
+            logging.debug("%s was too large at %s - new size is %s" % (
+                iconpath, oldsize, im.size))
             im.save(iconpath, "PNG")
 
-        else:
-            if options.verbose:
-                print iconpath, "is small enough:", im.size
+    except Exception, e:
+        logging.error("Failed resizing {0} - {1}".format(iconpath, e))
 
-    except Exception,e:
-        print "ERROR: Failed resizing {0} - {1}".format(iconpath, e)
 
 def resize_all_icons(repodirs):
     """Resize all icons that exceed the max size
@@ -307,6 +326,53 @@ def resize_all_icons(repodirs):
             for iconpath in glob.glob(icon_glob):
                 resize_icon(iconpath, density)
 
+
+cert_path_regex = re.compile(r'^META-INF/.*\.RSA$')
+
+
+def getsig(apkpath):
+    """ Get the signing certificate of an apk. To get the same md5 has that
+    Android gets, we encode the .RSA certificate in a specific format and pass
+    it hex-encoded to the md5 digest algorithm.
+
+    :param apkpath: path to the apk
+    :returns: A string containing the md5 of the signature of the apk or None
+              if an error occurred.
+    """
+
+    cert = None
+
+    with zipfile.ZipFile(apkpath, 'r') as apk:
+
+        certs = [n for n in apk.namelist() if cert_path_regex.match(n)]
+
+        if len(certs) < 1:
+            logging.error("Found no signing certificates on %s" % apkpath)
+            return None
+        if len(certs) > 1:
+            logging.error("Found multiple signing certificates on %s" % apkpath)
+            return None
+
+        cert = apk.read(certs[0])
+
+    content = decoder.decode(cert, asn1Spec=rfc2315.ContentInfo())[0]
+    if content.getComponentByName('contentType') != rfc2315.signedData:
+        logging.error("Unexpected format.")
+        return None
+
+    content = decoder.decode(content.getComponentByName('content'),
+                             asn1Spec=rfc2315.SignedData())[0]
+    try:
+        certificates = content.getComponentByName('certificates')
+    except PyAsn1Error:
+        logging.error("Certificates not found.")
+        return None
+
+    cert_encoded = encoder.encode(certificates)[4:]
+
+    return md5(cert_encoded.encode('hex')).hexdigest()
+
+
 def scan_apks(apps, apkcache, repodir, knownapks):
     """Scan the apks in the given repo directory.
 
@@ -344,44 +410,45 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
         apkfilename = apkfile[len(repodir) + 1:]
         if ' ' in apkfilename:
-            print "No spaces in APK filenames!"
+            logging.critical("Spaces in filenames are not allowed.")
             sys.exit(1)
 
         if apkfilename in apkcache:
-            if options.verbose:
-                print "Reading " + apkfilename + " from cache"
+            logging.debug("Reading " + apkfilename + " from cache")
             thisinfo = apkcache[apkfilename]
 
         else:
-
-            if not options.quiet:
-                print "Processing " + apkfilename
+            logging.debug("Processing " + apkfilename)
             thisinfo = {}
             thisinfo['apkname'] = apkfilename
             srcfilename = apkfilename[:-4] + "_src.tar.gz"
             if os.path.exists(os.path.join(repodir, srcfilename)):
                 thisinfo['srcname'] = srcfilename
             thisinfo['size'] = os.path.getsize(apkfile)
-            thisinfo['permissions'] = []
-            thisinfo['features'] = []
+            thisinfo['permissions'] = set()
+            thisinfo['features'] = set()
             thisinfo['icons_src'] = {}
             thisinfo['icons'] = {}
-            p = subprocess.Popen([os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'aapt'),
-                                  'dump', 'badging', apkfile],
-                                 stdout=subprocess.PIPE)
-            output = p.communicate()[0]
+            p = SilentPopen([config['aapt'], 'dump', 'badging', apkfile])
             if p.returncode != 0:
-                print "ERROR: Failed to get apk information"
-                sys.exit(1)
-            for line in output.splitlines():
+                if options.delete_unknown:
+                    if os.path.exists(apkfile):
+                        logging.error("Failed to get apk information, deleting " + apkfile)
+                        os.remove(apkfile)
+                    else:
+                        logging.error("Could not find {0} to remove it".format(apkfile))
+                else:
+                    logging.error("Failed to get apk information, skipping " + apkfile)
+                continue
+            for line in p.output.splitlines():
                 if line.startswith("package:"):
                     try:
                         thisinfo['id'] = re.match(name_pat, line).group(1)
                         thisinfo['versioncode'] = int(re.match(vercode_pat, line).group(1))
                         thisinfo['version'] = re.match(vername_pat, line).group(1)
                     except Exception, e:
-                        print "Package matching failed: " + str(e)
-                        print "Line was: " + line
+                        logging.error("Package matching failed: " + str(e))
+                        logging.info("Line was: " + line)
                         sys.exit(1)
                 elif line.startswith("application:"):
                     thisinfo['name'] = re.match(label_pat, line).group(1)
@@ -391,6 +458,8 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                         thisinfo['icons_src']['-1'] = match.group(1)
                 elif line.startswith("launchable-activity:"):
                     # Only use launchable-activity as fallback to application
+                    if not thisinfo['name']:
+                        thisinfo['name'] = re.match(label_pat, line).group(1)
                     if '-1' not in thisinfo['icons_src']:
                         match = re.match(icon_pat_nodpi, line)
                         if match:
@@ -402,7 +471,14 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                         path = match.group(2)
                         thisinfo['icons_src'][density] = path
                 elif line.startswith("sdkVersion:"):
-                    thisinfo['sdkversion'] = re.match(sdkversion_pat, line).group(1)
+                    m = re.match(sdkversion_pat, line)
+                    if m is None:
+                        logging.error(line.replace('sdkVersion:', '')
+                                      + ' is not a valid minSdkVersion!')
+                    else:
+                        thisinfo['sdkversion'] = m.group(1)
+                elif line.startswith("maxSdkVersion:"):
+                    thisinfo['maxsdkversion'] = re.match(sdkversion_pat, line).group(1)
                 elif line.startswith("native-code:"):
                     thisinfo['nativecode'] = []
                     for arch in line[13:].split(' '):
@@ -411,24 +487,24 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     perm = re.match(string_pat, line).group(1)
                     if perm.startswith("android.permission."):
                         perm = perm[19:]
-                    thisinfo['permissions'].append(perm)
+                    thisinfo['permissions'].add(perm)
                 elif line.startswith("uses-feature:"):
                     perm = re.match(string_pat, line).group(1)
-                    #Filter out this, it's only added with the latest SDK tools and
-                    #causes problems for lots of apps.
-                    if (perm != "android.hardware.screen.portrait" and
-                        perm != "android.hardware.screen.landscape"):
+                    # Filter out this, it's only added with the latest SDK tools and
+                    # causes problems for lots of apps.
+                    if perm != "android.hardware.screen.portrait" \
+                            and perm != "android.hardware.screen.landscape":
                         if perm.startswith("android.feature."):
                             perm = perm[16:]
-                        thisinfo['features'].append(perm)
+                        thisinfo['features'].add(perm)
 
-            if not 'sdkversion' in thisinfo:
-                print "  WARNING: no SDK version information found"
+            if 'sdkversion' not in thisinfo:
+                logging.warn("No SDK version information found in {0}".format(apkfile))
                 thisinfo['sdkversion'] = 0
 
             # Check for debuggable apks...
             if common.isApkDebuggable(apkfile, config):
-                print "WARNING: {0} is debuggable... {1}".format(apkfile, line)
+                logging.warn('{0} is set to android:debuggable="true"'.format(apkfile))
 
             # Calculate the sha256...
             sha = hashlib.sha256()
@@ -440,28 +516,27 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     sha.update(t)
                 thisinfo['sha256'] = sha.hexdigest()
 
-            # Get the signature (or md5 of, to be precise)...
-            getsig_dir = os.path.join(os.path.dirname(__file__), 'getsig')
-            if not os.path.exists(getsig_dir + "/getsig.class"):
-                print "ERROR: getsig.class not found. To fix:"
-                print "\tcd " + getsig_dir
-                print "\t./make.sh"
-                sys.exit(1)
-            p = subprocess.Popen(['java', '-cp', os.path.join(os.path.dirname(__file__), 'getsig'),
-                        'getsig', os.path.join(os.getcwd(), apkfile)], stdout=subprocess.PIPE)
-            output = p.communicate()[0]
+            # verify the jar signature is correct
+            args = ['jarsigner', '-verify']
             if options.verbose:
-                print output
-            if p.returncode != 0 or not output.startswith('Result:'):
-                print "ERROR: Failed to get apk signature"
+                args += ['-verbose', '-certs']
+            args += apkfile
+            p = FDroidPopen(args)
+            if p.returncode != 0:
+                logging.critical(apkfile + " has a bad signature!")
+                sys.exit(1)
+
+            # Get the signature (or md5 of, to be precise)...
+            thisinfo['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
+            if not thisinfo['sig']:
+                logging.critical("Failed to get apk signature")
                 sys.exit(1)
-            thisinfo['sig'] = output[7:].strip()
 
             apk = zipfile.ZipFile(apkfile, 'r')
 
             iconfilename = "%s.%s.png" % (
-                    thisinfo['id'],
-                    thisinfo['versioncode'])
+                thisinfo['id'],
+                thisinfo['versioncode'])
 
             # Extract the icon file...
             densities = get_densities()
@@ -478,10 +553,10 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     iconfile = open(icondest, 'wb')
                     iconfile.write(apk.read(iconsrc))
                     iconfile.close()
-                    thisinfo['icons'][density] = iconfilename 
+                    thisinfo['icons'][density] = iconfilename
 
                 except:
-                    print "WARNING: Error retrieving icon file"
+                    logging.warn("Error retrieving icon file")
                     del thisinfo['icons'][density]
                     del thisinfo['icons_src'][density]
                     empty_densities.append(density)
@@ -489,21 +564,27 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             if '-1' in thisinfo['icons_src']:
                 iconsrc = thisinfo['icons_src']['-1']
                 iconpath = os.path.join(
-                        get_icon_dir(repodir, None), iconfilename)
+                    get_icon_dir(repodir, None), iconfilename)
                 iconfile = open(iconpath, 'wb')
                 iconfile.write(apk.read(iconsrc))
                 iconfile.close()
-                im = Image.open(iconpath)
-                dpi = px_to_dpi(im.size[0])
-                for density in densities:
-                    if density in thisinfo['icons']:
-                        break
-                    if dpi >= int(density):
-                        thisinfo['icons'][density] = iconfilename
-                        shutil.move(iconpath,
-                                os.path.join(get_icon_dir(repodir, density), iconfilename))
-                        empty_densities.remove(density)
-                        break
+                try:
+                    im = Image.open(iconpath)
+                    dpi = px_to_dpi(im.size[0])
+                    for density in densities:
+                        if density in thisinfo['icons']:
+                            break
+                        if density == densities[-1] or dpi >= int(density):
+                            thisinfo['icons'][density] = iconfilename
+                            shutil.move(iconpath,
+                                        os.path.join(get_icon_dir(repodir, density), iconfilename))
+                            empty_densities.remove(density)
+                            break
+                except Exception, e:
+                    logging.warn("Failed reading {0} - {1}".format(iconpath, e))
+
+            if thisinfo['icons']:
+                thisinfo['icon'] = iconfilename
 
             apk.close()
 
@@ -515,15 +596,19 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     continue
                 if last_density is None:
                     continue
-                if options.verbose:
-                    print "Density %s not available, resizing down from %s" % (
-                            density, last_density)
+                logging.debug("Density %s not available, resizing down from %s"
+                              % (density, last_density))
 
                 last_iconpath = os.path.join(
-                        get_icon_dir(repodir, last_density), iconfilename)
+                    get_icon_dir(repodir, last_density), iconfilename)
                 iconpath = os.path.join(
-                        get_icon_dir(repodir, density), iconfilename)
-                im = Image.open(last_iconpath)
+                    get_icon_dir(repodir, density), iconfilename)
+                try:
+                    im = Image.open(last_iconpath)
+                except:
+                    logging.warn("Invalid image file at %s" % last_iconpath)
+                    continue
+
                 size = dpi_to_px(density)
 
                 im.thumbnail((size, size), Image.ANTIALIAS)
@@ -538,13 +623,12 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     continue
                 if last_density is None:
                     continue
-                if options.verbose:
-                    print "Density %s not available, copying from lower density %s" % (
-                            density, last_density)
+                logging.debug("Density %s not available, copying from lower density %s"
+                              % (density, last_density))
 
                 shutil.copyfile(
-                        os.path.join(get_icon_dir(repodir, last_density), iconfilename),
-                        os.path.join(get_icon_dir(repodir, density), iconfilename))
+                    os.path.join(get_icon_dir(repodir, last_density), iconfilename),
+                    os.path.join(get_icon_dir(repodir, density), iconfilename))
 
                 empty_densities.remove(density)
 
@@ -554,9 +638,10 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                 resize_icon(icondest, density)
 
             # Copy from icons-mdpi to icons since mdpi is the baseline density
-            shutil.copyfile(
-                    os.path.join(get_icon_dir(repodir, '160'), iconfilename),
-                    os.path.join(get_icon_dir(repodir, None), iconfilename))
+            baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
+            if os.path.isfile(baseline):
+                shutil.copyfile(baseline,
+                                os.path.join(get_icon_dir(repodir, None), iconfilename))
 
             # Record in known apks, getting the added date at the same time..
             added = knownapks.recordapk(thisinfo['apkname'], thisinfo['id'])
@@ -573,7 +658,8 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
 repo_pubkey_fingerprint = None
 
-def make_index(apps, apks, repodir, archive, categories):
+
+def make_index(apps, sortedids, apks, repodir, archive, categories):
     """Make a repo index.
 
     :param apps: fully populated apps list
@@ -590,6 +676,7 @@ def make_index(apps, apks, repodir, archive, categories):
         el = doc.createElement(name)
         el.appendChild(doc.createTextNode(value))
         parent.appendChild(el)
+
     def addElementCDATA(name, value, doc, parent):
         el = doc.createElement(name)
         el.appendChild(doc.createCDATASection(value))
@@ -616,39 +703,41 @@ def make_index(apps, apks, repodir, archive, categories):
         repoel.setAttribute("url", config['repo_url'])
         addElement('description', config['repo_description'], doc, repoel)
 
-    repoel.setAttribute("version", "10")
+    repoel.setAttribute("version", "12")
     repoel.setAttribute("timestamp", str(int(time.time())))
 
-    if config['repo_keyalias']:
+    if 'repo_keyalias' in config:
 
         # Generate a certificate fingerprint the same way keytool does it
         # (but with slightly different formatting)
         def cert_fingerprint(data):
-            digest = hashlib.sha1(data).digest()
+            digest = hashlib.sha256(data).digest()
             ret = []
-            for i in range(4):
-                ret.append(":".join("%02X" % ord(b) for b in digest[i*5:i*5+5]))
+            ret.append(' '.join("%02X" % ord(b) for b in digest))
             return " ".join(ret)
 
         def extract_pubkey():
-            p = subprocess.Popen(['keytool', '-exportcert',
-                                  '-alias', config['repo_keyalias'],
-                                  '-keystore', config['keystore'],
-                                  '-storepass', config['keystorepass']],
-                                 stdout=subprocess.PIPE)
-            cert = p.communicate()[0]
+            p = FDroidPopen(['keytool', '-exportcert',
+                             '-alias', config['repo_keyalias'],
+                             '-keystore', config['keystore'],
+                             '-storepass:file', config['keystorepassfile']]
+                            + config['smartcardoptions'], output=False)
             if p.returncode != 0:
-                print "ERROR: Failed to get repo pubkey"
+                msg = "Failed to get repo pubkey!"
+                if config['keystore'] == 'NONE':
+                    msg += ' Is your crypto smartcard plugged in?'
+                logging.critical(msg)
                 sys.exit(1)
             global repo_pubkey_fingerprint
-            repo_pubkey_fingerprint = cert_fingerprint(cert)
-            return "".join("%02x" % ord(b) for b in cert)
+            repo_pubkey_fingerprint = cert_fingerprint(p.output)
+            return "".join("%02x" % ord(b) for b in p.output)
 
         repoel.setAttribute("pubkey", extract_pubkey())
 
     root.appendChild(repoel)
 
-    for app in apps:
+    for appid in sortedids:
+        app = apps[appid]
 
         if app['Disabled'] is not None:
             continue
@@ -656,7 +745,7 @@ def make_index(apps, apks, repodir, archive, categories):
         # Get a list of the apks for this app...
         apklist = []
         for apk in apks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 apklist.append(apk)
 
         if len(apklist) == 0:
@@ -675,21 +764,22 @@ def make_index(apps, apks, repodir, archive, categories):
         addElement('summary', app['Summary'], doc, apel)
         if app['icon']:
             addElement('icon', app['icon'], doc, apel)
-        def linkres(link):
-            for app in apps:
-                if app['id'] == link:
-                    return ("fdroid.app:" + link, app['Name'])
-            raise MetaDataException("Cannot resolve app id " + link)
+
+        def linkres(appid):
+            if appid in apps:
+                return ("fdroid.app:" + appid, apps[appid]['Name'])
+            raise MetaDataException("Cannot resolve app id " + appid)
+
         addElement('desc',
-                metadata.description_html(app['Description'], linkres), doc, apel)
+                   metadata.description_html(app['Description'], linkres),
+                   doc, apel)
         addElement('license', app['License'], doc, apel)
         if 'Categories' in app:
-            appcategories = [c.strip() for c in app['Categories'].split(',')]
-            addElement('categories', ','.join(appcategories), doc, apel)
+            addElement('categories', ','.join(app["Categories"]), doc, apel)
             # We put the first (primary) category in LAST, which will have
             # the desired effect of making clients that only understand one
             # category see that one.
-            addElement('category', appcategories[0], doc, apel)
+            addElement('category', app["Categories"][0], doc, apel)
         addElement('web', app['Web Site'], doc, apel)
         addElement('source', app['Source Code'], doc, apel)
         addElement('tracker', app['Issue Tracker'], doc, apel)
@@ -732,10 +822,9 @@ def make_index(apps, apks, repodir, archive, categories):
 
         # Check for duplicates - they will make the client unhappy...
         for i in range(len(apklist) - 1):
-            if apklist[i]['versioncode'] == apklist[i+1]['versioncode']:
-                print "ERROR - duplicate versions"
-                print apklist[i]['apkname']
-                print apklist[i+1]['apkname']
+            if apklist[i]['versioncode'] == apklist[i + 1]['versioncode']:
+                logging.critical("duplicate versions: '%s' - '%s'" % (
+                    apklist[i]['apkname'], apklist[i + 1]['apkname']))
                 sys.exit(1)
 
         for apk in apklist:
@@ -747,7 +836,7 @@ def make_index(apps, apks, repodir, archive, categories):
             if 'srcname' in apk:
                 addElement('srcname', apk['srcname'], doc, apkel)
             for hash_type in ['sha256']:
-                if not hash_type in apk:
+                if hash_type not in apk:
                     continue
                 hashel = doc.createElement("hash")
                 hashel.setAttribute("type", hash_type)
@@ -756,11 +845,13 @@ def make_index(apps, apks, repodir, archive, categories):
             addElement('sig', apk['sig'], doc, apkel)
             addElement('size', str(apk['size']), doc, apkel)
             addElement('sdkver', str(apk['sdkversion']), doc, apkel)
+            if 'maxsdkversion' in apk:
+                addElement('maxsdkver', str(apk['maxsdkversion']), doc, apkel)
             if 'added' in apk:
                 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
             if app['Requires Root']:
                 if 'ACCESS_SUPERUSER' not in apk['permissions']:
-                    apk['permissions'].append('ACCESS_SUPERUSER')
+                    apk['permissions'].add('ACCESS_SUPERUSER')
 
             if len(apk['permissions']) > 0:
                 addElement('permissions', ','.join(apk['permissions']), doc, apkel)
@@ -777,37 +868,34 @@ def make_index(apps, apks, repodir, archive, categories):
     of.write(output)
     of.close()
 
-    if config['repo_keyalias'] is not None:
+    if 'repo_keyalias' in config:
 
-        if not options.quiet:
-            print "Creating signed index."
-            print "Key fingerprint:", repo_pubkey_fingerprint
+        logging.info("Creating signed index with this key (SHA256):")
+        logging.info("%s" % repo_pubkey_fingerprint)
 
-        #Create a jar of the index...
-        p = subprocess.Popen(['jar', 'cf', 'index.jar', 'index.xml'],
-            cwd=repodir, stdout=subprocess.PIPE)
-        output = p.communicate()[0]
-        if options.verbose:
-            print output
+        # Create a jar of the index...
+        p = FDroidPopen(['jar', 'cf', 'index.jar', 'index.xml'], cwd=repodir)
         if p.returncode != 0:
-            print "ERROR: Failed to create jar file"
+            logging.critical("Failed to create jar file")
             sys.exit(1)
 
         # Sign the index...
-        p = subprocess.Popen(['jarsigner', '-keystore', config['keystore'],
-            '-storepass', config['keystorepass'], '-keypass', config['keypass'],
-            '-digestalg', 'SHA1', '-sigalg', 'MD5withRSA',
-            os.path.join(repodir, 'index.jar') , config['repo_keyalias']], stdout=subprocess.PIPE)
-        output = p.communicate()[0]
+        args = ['jarsigner', '-keystore', config['keystore'],
+                '-storepass:file', config['keystorepassfile'],
+                '-digestalg', 'SHA1', '-sigalg', 'MD5withRSA',
+                os.path.join(repodir, 'index.jar'), config['repo_keyalias']]
+        if config['keystore'] == 'NONE':
+            args += config['smartcardoptions']
+        else:  # smardcards never use -keypass
+            args += ['-keypass:file', config['keypassfile']]
+        p = FDroidPopen(args)
+        # TODO keypass should be sent via stdin
         if p.returncode != 0:
-            print "Failed to sign index"
-            print output
+            logging.critical("Failed to sign index")
             sys.exit(1)
-        if options.verbose:
-            print output
 
     # Copy the repo icon into the repo directory...
-    icon_dir = os.path.join(repodir ,'icons')
+    icon_dir = os.path.join(repodir'icons')
     iconfilename = os.path.join(icon_dir, os.path.basename(config['repo_icon']))
     shutil.copyfile(config['repo_icon'], iconfilename)
 
@@ -820,15 +908,14 @@ def make_index(apps, apks, repodir, archive, categories):
     f.close()
 
 
+def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
 
-def archive_old_apks(apps, apks, repodir, archivedir, defaultkeepversions):
-
-    for app in apps:
+    for appid, app in apps.iteritems():
 
         # Get a list of the apks for this app...
         apklist = []
         for apk in apks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 apklist.append(apk)
 
         # Sort the apk list into version order...
@@ -841,30 +928,40 @@ def archive_old_apks(apps, apks, repodir, archivedir, defaultkeepversions):
 
         if len(apklist) > keepversions:
             for apk in apklist[keepversions:]:
-                print "Moving " + apk['apkname'] + " to archive"
+                logging.info("Moving " + apk['apkname'] + " to archive")
                 shutil.move(os.path.join(repodir, apk['apkname']),
-                    os.path.join(archivedir, apk['apkname']))
+                            os.path.join(archivedir, apk['apkname']))
                 if 'srcname' in apk:
                     shutil.move(os.path.join(repodir, apk['srcname']),
-                        os.path.join(archivedir, apk['srcname']))
+                                os.path.join(archivedir, apk['srcname']))
+                    # Move GPG signature too...
+                    sigfile = apk['srcname'] + '.asc'
+                    sigsrc = os.path.join(repodir, sigfile)
+                    if os.path.exists(sigsrc):
+                        shutil.move(sigsrc, os.path.join(archivedir, sigfile))
+
+                archapks.append(apk)
                 apks.remove(apk)
 
 
 config = None
 options = None
 
+
 def main():
 
     global config, options
 
     # Parse command line...
     parser = OptionParser()
-    parser.add_option("-c", "--createmeta", action="store_true", default=False,
+    parser.add_option("-c", "--create-metadata", action="store_true", default=False,
                       help="Create skeleton metadata files that are missing")
+    parser.add_option("--delete-unknown", action="store_true", default=False,
+                      help="Delete APKs without metadata from the repo")
     parser.add_option("-v", "--verbose", action="store_true", default=False,
                       help="Spew out even more information than normal")
     parser.add_option("-q", "--quiet", action="store_true", default=False,
-                      help="No output, except for warnings and errors")
+                      help="Restrict output to warnings and errors")
     parser.add_option("-b", "--buildreport", action="store_true", default=False,
                       help="Report on build data status")
     parser.add_option("-i", "--interactive", default=False, action="store_true",
@@ -872,8 +969,8 @@ def main():
     parser.add_option("-I", "--icons", action="store_true", default=False,
                       help="Resize all the icons exceeding the max pixel size and exit")
     parser.add_option("-e", "--editor", default="/etc/alternatives/editor",
-                      help="Specify editor to use in interactive mode. Default "+
-                          "is /etc/alternatives/editor")
+                      help="Specify editor to use in interactive mode. Default " +
+                      "is /etc/alternatives/editor")
     parser.add_option("-w", "--wiki", default=False, action="store_true",
                       help="Update the wiki")
     parser.add_option("", "--pretty", action="store_true", default=False,
@@ -894,16 +991,20 @@ def main():
         resize_all_icons(repodirs)
         sys.exit(0)
 
+    # check that icons exist now, rather than fail at the end of `fdroid update`
+    for k in ['repo_icon', 'archive_icon']:
+        if k in config:
+            if not os.path.exists(config[k]):
+                logging.critical(k + ' "' + config[k] + '" does not exist! Correct it in config.py.')
+                sys.exit(1)
+
     # Get all apps...
     apps = metadata.read_metadata()
 
     # Generate a list of categories...
-    categories = []
-    for app in apps:
-        cats = app['Categories'].split(',')
-        for cat in cats:
-            if cat not in categories:
-                categories.append(cat)
+    categories = set()
+    for app in apps.itervalues():
+        categories.update(app['Categories'])
 
     # Read known apks data (will be updated and written back when we've finished)
     knownapks = common.KnownApks()
@@ -925,6 +1026,43 @@ def main():
     if cc:
         cachechanged = True
 
+    # Generate warnings for apk's with no metadata (or create skeleton
+    # metadata files, if requested on the command line)
+    newmetadata = False
+    for apk in apks:
+        if apk['id'] not in apps:
+            if options.create_metadata:
+                if 'name' not in apk:
+                    logging.error(apk['id'] + ' does not have a name! Skipping...')
+                    continue
+                f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w')
+                f.write("License:Unknown\n")
+                f.write("Web Site:\n")
+                f.write("Source Code:\n")
+                f.write("Issue Tracker:\n")
+                f.write("Summary:" + apk['name'] + "\n")
+                f.write("Description:\n")
+                f.write(apk['name'] + "\n")
+                f.write(".\n")
+                f.close()
+                logging.info("Generated skeleton metadata for " + apk['id'])
+                newmetadata = True
+            else:
+                msg = apk['apkname'] + " (" + apk['id'] + ") has no metadata!"
+                if options.delete_unknown:
+                    logging.warn(msg + "\n\tdeleting: repo/" + apk['apkname'])
+                    rmf = os.path.join(repodirs[0], apk['apkname'])
+                    if not os.path.exists(rmf):
+                        logging.error("Could not find {0} to remove it".format(rmf))
+                    else:
+                        os.remove(rmf)
+                else:
+                    logging.warn(msg + "\n\tUse `fdroid update -c` to create it.")
+
+    # update the metadata with the newly created ones included
+    if newmetadata:
+        apps = metadata.read_metadata()
+
     # Scan the archive repo for apks as well
     if len(repodirs) > 1:
         archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks)
@@ -937,12 +1075,12 @@ def main():
     # level. When doing this, we use the info from the most recent version's apk.
     # We deal with figuring out when the app was added and last updated at the
     # same time.
-    for app in apps:
+    for appid, app in apps.iteritems():
         bestver = 0
         added = None
         lastupdated = None
         for apk in apks + archapks:
-            if apk['id'] == app['id']:
+            if apk['id'] == appid:
                 if apk['versioncode'] > bestver:
                     bestver = apk['versioncode']
                     bestapk = apk
@@ -956,20 +1094,17 @@ def main():
         if added:
             app['added'] = added
         else:
-            if options.verbose:
-                print "WARNING: Don't know when " + app['id'] + " was added"
+            logging.warn("Don't know when " + appid + " was added")
         if lastupdated:
             app['lastupdated'] = lastupdated
         else:
-            if options.verbose:
-                print "WARNING: Don't know when " + app['id'] + " was last updated"
+            logging.warn("Don't know when " + appid + " was last updated")
 
         if bestver == 0:
             if app['Name'] is None:
-                app['Name'] = app['id']
+                app['Name'] = app['Auto Name'] or appid
             app['icon'] = None
-            if options.verbose and app['Disabled'] is None:
-                print "WARNING: Application " + app['id'] + " has no packages"
+            logging.warn("Application " + appid + " has no packages")
         else:
             if app['Name'] is None:
                 app['Name'] = bestapk['name']
@@ -978,43 +1113,18 @@ def main():
     # Sort the app list by name, then the web site doesn't have to by default.
     # (we had to wait until we'd scanned the apks to do this, because mostly the
     # name comes from there!)
-    apps = sorted(apps, key=lambda app: app['Name'].upper())
-
-    # Generate warnings for apk's with no metadata (or create skeleton
-    # metadata files, if requested on the command line)
-    for apk in apks:
-        found = False
-        for app in apps:
-            if app['id'] == apk['id']:
-                found = True
-                break
-        if not found:
-            if options.createmeta:
-                f = open(os.path.join('metadata', apk['id'] + '.txt'), 'w')
-                f.write("License:Unknown\n")
-                f.write("Web Site:\n")
-                f.write("Source Code:\n")
-                f.write("Issue Tracker:\n")
-                f.write("Summary:" + apk['name'] + "\n")
-                f.write("Description:\n")
-                f.write(apk['name'] + "\n")
-                f.write(".\n")
-                f.close()
-                print "Generated skeleton metadata for " + apk['id']
-            else:
-                print "WARNING: " + apk['apkname'] + " (" + apk['id'] + ") has no metadata"
-                print "       " + apk['name'] + " - " + apk['version']
+    sortedids = sorted(apps.iterkeys(), key=lambda appid: apps[appid]['Name'].upper())
 
     if len(repodirs) > 1:
-        archive_old_apks(apps, apks, repodirs[0], repodirs[1], config['archive_older'])
+        archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
 
     # Make the index for the main repo...
-    make_index(apps, apks, repodirs[0], False, categories)
+    make_index(apps, sortedids, apks, repodirs[0], False, categories)
 
     # If there's an archive repo,  make the index for it. We already scanned it
     # earlier on.
     if len(repodirs) > 1:
-        make_index(apps, archapks, repodirs[1], True, categories)
+        make_index(apps, sortedids, archapks, repodirs[1], True, categories)
 
     if config['update_stats']:
 
@@ -1027,13 +1137,11 @@ def main():
             for line in file(os.path.join('stats', 'latestapps.txt')):
                 appid = line.rstrip()
                 data += appid + "\t"
-                for app in apps:
-                    if app['id'] == appid:
-                        data += app['Name'] + "\t"
-                        if app['icon'] is not None:
-                            data += app['icon'] + "\t"
-                        data += app['License'] + "\n"
-                        break
+                app = apps[appid]
+                data += app['Name'] + "\t"
+                if app['icon'] is not None:
+                    data += app['icon'] + "\t"
+                data += app['License'] + "\n"
             f = open(os.path.join(repodirs[0], 'latestapps.dat'), 'w')
             f.write(data)
             f.close()
@@ -1044,10 +1152,9 @@ def main():
 
     # Update the wiki...
     if options.wiki:
-        update_wiki(apps, apks + archapks)
+        update_wiki(apps, sortedids, apks + archapks)
 
-    print "Finished."
+    logging.info("Finished.")
 
 if __name__ == "__main__":
     main()
-