chiark / gitweb /
Merge branch 'p1' into 'master'
[fdroidserver.git] / fdroidserver / update.py
index f44f3549814e69b2c4e6115582882a898fd45cf6..38a61c8b3aae245ff22b135041ade42068217c96 100644 (file)
@@ -27,8 +27,10 @@ import socket
 import zipfile
 import hashlib
 import pickle
+import urlparse
+from datetime import datetime, timedelta
 from xml.dom.minidom import Document
-from optparse import OptionParser
+from argparse import ArgumentParser
 import time
 from pyasn1.error import PyAsn1Error
 from pyasn1.codec.der import decoder, encoder
@@ -44,9 +46,9 @@ import metadata
 from common import FDroidPopen, SdkToolsPopen
 from metadata import MetaDataException
 
+screen_densities = ['640', '480', '320', '240', '160', '120']
 
-def get_densities():
-    return ['640', '480', '320', '240', '160', '120']
+all_screen_densities = ['0'] + screen_densities
 
 
 def dpi_to_px(density):
@@ -58,15 +60,19 @@ def px_to_dpi(px):
 
 
 def get_icon_dir(repodir, density):
-    if density is None:
+    if density == '0':
         return os.path.join(repodir, "icons")
     return os.path.join(repodir, "icons-%s" % density)
 
 
 def get_icon_dirs(repodir):
-    for density in get_densities():
+    for density in screen_densities:
+        yield get_icon_dir(repodir, density)
+
+
+def get_all_icon_dirs(repodir):
+    for density in all_screen_densities:
         yield get_icon_dir(repodir, density)
-    yield os.path.join(repodir, "icons")
 
 
 def update_wiki(apps, sortedids, apks):
@@ -89,40 +95,45 @@ def update_wiki(apps, sortedids, apks):
         app = apps[appid]
 
         wikidata = ''
-        if app['Disabled']:
-            wikidata += '{{Disabled|' + app['Disabled'] + '}}\n'
-        if app['AntiFeatures']:
-            for af in app['AntiFeatures'].split(','):
+        if app.Disabled:
+            wikidata += '{{Disabled|' + app.Disabled + '}}\n'
+        if app.AntiFeatures:
+            for af in app.AntiFeatures:
                 wikidata += '{{AntiFeature|' + af + '}}\n'
-        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|dogecoin=%s|license=%s|root=%s}}\n' % (
+        if app.RequiresRoot:
+            requiresroot = 'Yes'
+        else:
+            requiresroot = 'No'
+        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' % (
             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['Changelog'],
-            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']
+            app.Name,
+            time.strftime('%Y-%m-%d', app.added) if app.added else '',
+            time.strftime('%Y-%m-%d', app.lastupdated) if app.lastupdated else '',
+            app.SourceCode,
+            app.IssueTracker,
+            app.WebSite,
+            app.Changelog,
+            app.Donate,
+            app.FlattrID,
+            app.Bitcoin,
+            app.Litecoin,
+            app.License,
+            requiresroot,
+            app.AuthorName,
+            app.AuthorEmail)
+
+        if app.Provides:
+            wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
+
+        wikidata += app.Summary
         wikidata += " - [https://f-droid.org/repository/browse/?fdid=" + appid + " view in repository]\n\n"
 
         wikidata += "=Description=\n"
-        wikidata += metadata.description_wiki(app['Description']) + "\n"
+        wikidata += metadata.description_wiki(app.Description) + "\n"
 
         wikidata += "=Maintainer Notes=\n"
-        if 'Maintainer Notes' in app:
-            wikidata += metadata.description_wiki(app['Maintainer Notes']) + "\n"
+        if app.MaintainerNotes:
+            wikidata += metadata.description_wiki(app.MaintainerNotes) + "\n"
         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...
@@ -132,32 +143,32 @@ def update_wiki(apps, sortedids, apks):
         buildfails = False
         for apk in apks:
             if apk['id'] == appid:
-                if str(apk['versioncode']) == app['Current Version Code']:
+                if str(apk['versioncode']) == app.CurrentVersionCode:
                     gotcurrentver = True
                 apklist.append(apk)
         # Include ones we can't build, as a special case...
-        for thisbuild in app['builds']:
-            if thisbuild['disable']:
-                if thisbuild['vercode'] == app['Current Version Code']:
+        for build in app.builds:
+            if build.disable:
+                if build.vercode == app.CurrentVersionCode:
                     cantupdate = True
                 # 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']
+                apklist.append({'versioncode': int(build.vercode),
+                                'version': build.version,
+                                'buildproblem': "The build for this version was manually disabled. Reason: {0}".format(build.disable),
                                 })
             else:
                 builtit = False
                 for apk in apklist:
-                    if apk['versioncode'] == int(thisbuild['vercode']):
+                    if apk['versioncode'] == int(build.vercode):
                         builtit = True
                         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_{1}|build log]].".format(appid, thisbuild['vercode'])
+                    apklist.append({'versioncode': int(build.vercode),
+                                    'version': build.version,
+                                    'buildproblem': "The build for this version appears to have failed. Check the [[{0}/lastbuild_{1}|build log]].".format(appid, build.vercode),
                                     })
-        if app['Current Version Code'] == '0':
+        if app.CurrentVersionCode == '0':
             cantupdate = True
         # Sort with most recent first...
         apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
@@ -169,13 +180,13 @@ def update_wiki(apps, sortedids, apks):
             wikidata += "We don't have the current version of this app."
         else:
             wikidata += "We have the current version of this app."
-        wikidata += " (Check mode: " + app['Update Check Mode'] + ") "
-        wikidata += " (Auto-update mode: " + app['Auto Update Mode'] + ")\n\n"
-        if len(app['No Source Since']) > 0:
-            wikidata += "This application has partially or entirely been missing source code since version " + app['No Source Since'] + ".\n\n"
-        if len(app['Current Version']) > 0:
-            wikidata += "The current (recommended) version is " + app['Current Version']
-            wikidata += " (version code " + app['Current Version Code'] + ").\n\n"
+        wikidata += " (Check mode: " + app.UpdateCheckMode + ") "
+        wikidata += " (Auto-update mode: " + app.AutoUpdateMode + ")\n\n"
+        if len(app.NoSourceSince) > 0:
+            wikidata += "This application has partially or entirely been missing source code since version " + app.NoSourceSince + ".\n\n"
+        if len(app.CurrentVersion) > 0:
+            wikidata += "The current (recommended) version is " + app.CurrentVersion
+            wikidata += " (version code " + app.CurrentVersionCode + ").\n\n"
         validapks = 0
         for apk in apklist:
             wikidata += "==" + apk['version'] + "==\n"
@@ -192,21 +203,21 @@ def update_wiki(apps, sortedids, apks):
             wikidata += "Version code: " + str(apk['versioncode']) + '\n'
 
         wikidata += '\n[[Category:' + wikicat + ']]\n'
-        if len(app['No Source Since']) > 0:
+        if len(app.NoSourceSince) > 0:
             wikidata += '\n[[Category:Apps missing source code]]\n'
-        if validapks == 0 and not app['Disabled']:
+        if validapks == 0 and not app.Disabled:
             wikidata += '\n[[Category:Apps with no packages]]\n'
-        if cantupdate and not app['Disabled']:
-            wikidata += "\n[[Category:Apps we can't update]]\n"
-        if buildfails and not app['Disabled']:
+        if cantupdate and not app.Disabled:
+            wikidata += "\n[[Category:Apps we cannot update]]\n"
+        if buildfails and not app.Disabled:
             wikidata += "\n[[Category:Apps with failing builds]]\n"
-        elif not gotcurrentver and not cantupdate and not app['Disabled'] and app['Update Check Mode'] != "Static":
+        elif not gotcurrentver and not cantupdate and not app.Disabled and app.UpdateCheckMode != "Static":
             wikidata += '\n[[Category:Apps to Update]]\n'
-        if app['Disabled']:
+        if app.Disabled:
             wikidata += '\n[[Category:Apps that are disabled]]\n'
-        if app['Update Check Mode'] == 'None' and not app['Disabled']:
+        if app.UpdateCheckMode == 'None' and not app.Disabled:
             wikidata += '\n[[Category:Apps with no update check]]\n'
-        for appcat in app['Categories']:
+        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
@@ -223,7 +234,7 @@ def update_wiki(apps, sortedids, apks):
         # Make a redirect from the name to the ID too, unless there's
         # already an existing page with the name and it isn't a redirect.
         noclobber = False
-        apppagename = app['Name'].replace('_', ' ')
+        apppagename = app.Name.replace('_', ' ')
         apppagename = apppagename.replace('{', '')
         apppagename = apppagename.replace('}', ' ')
         apppagename = apppagename.replace(':', ' ')
@@ -282,19 +293,29 @@ def delete_disabled_builds(apps, apkcache, repodirs):
     :param repodirs: the repo directories to process
     """
     for appid, app in apps.iteritems():
-        for build in app['builds']:
-            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, ascpath]:
-                        if os.path.exists(name):
-                            logging.warn("Deleting disabled build output " + apkfilename)
-                            os.remove(name)
-                if apkfilename in apkcache:
-                    del apkcache[apkfilename]
+        for build in app.builds:
+            if not build.disable:
+                continue
+            apkfilename = appid + '_' + str(build.vercode) + '.apk'
+            iconfilename = "%s.%s.png" % (
+                appid,
+                build.vercode)
+            for repodir in repodirs:
+                files = [
+                    os.path.join(repodir, apkfilename),
+                    os.path.join(repodir, apkfilename + '.asc'),
+                    os.path.join(repodir, apkfilename[:-4] + "_src.tar.gz"),
+                ]
+                for density in all_screen_densities:
+                    repo_dir = get_icon_dir(repodir, density)
+                    files.append(os.path.join(repo_dir, iconfilename))
+
+                for f in files:
+                    if os.path.exists(f):
+                        logging.info("Deleting disabled build output " + f)
+                        os.remove(f)
+            if apkfilename in apkcache:
+                del apkcache[apkfilename]
 
 
 def resize_icon(iconpath, density):
@@ -313,7 +334,7 @@ def resize_icon(iconpath, density):
                 iconpath, oldsize, im.size))
             im.save(iconpath, "PNG")
 
-    except Exception, e:
+    except Exception as e:
         logging.error("Failed resizing {0} - {1}".format(iconpath, e))
 
 
@@ -323,7 +344,7 @@ def resize_all_icons(repodirs):
     :param repodirs: the repo directories to process
     """
     for repodir in repodirs:
-        for density in get_densities():
+        for density in screen_densities:
             icon_dir = get_icon_dir(repodir, density)
             icon_glob = os.path.join(icon_dir, '*.png')
             for iconpath in glob.glob(icon_glob):
@@ -347,7 +368,7 @@ def getsig(apkpath):
     cert = None
 
     # verify the jar signature is correct
-    args = ['jarsigner', '-verify', apkpath]
+    args = [config['jarsigner'], '-verify', apkpath]
     p = FDroidPopen(args)
     if p.returncode != 0:
         logging.critical(apkpath + " has a bad signature!")
@@ -384,7 +405,7 @@ def getsig(apkpath):
     return md5(cert_encoded.encode('hex')).hexdigest()
 
 
-def scan_apks(apps, apkcache, repodir, knownapks):
+def scan_apks(apps, apkcache, repodir, knownapks, use_date_from_apk=False):
     """Scan the apks in the given repo directory.
 
     This also extracts the icons.
@@ -393,14 +414,15 @@ def scan_apks(apps, apkcache, repodir, knownapks):
     :param apkcache: current apk cache information
     :param repodir: repo directory to scan
     :param knownapks: known apks info
+    :param use_date_from_apk: use date from APK (instead of current date)
+                              for newly added APKs
     :returns: (apks, cachechanged) where apks is a list of apk information,
               and cachechanged is True if the apkcache got changed.
     """
 
     cachechanged = False
 
-    icon_dirs = get_icon_dirs(repodir)
-    for icon_dir in icon_dirs:
+    for icon_dir in get_all_icon_dirs(repodir):
         if os.path.exists(icon_dir):
             if options.clean:
                 shutil.rmtree(icon_dir)
@@ -436,8 +458,8 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
         usecache = False
         if apkfilename in apkcache:
-            thisinfo = apkcache[apkfilename]
-            if thisinfo['sha256'] == shasum:
+            apk = apkcache[apkfilename]
+            if apk['sha256'] == shasum:
                 logging.debug("Reading " + apkfilename + " from cache")
                 usecache = True
             else:
@@ -445,17 +467,17 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
         if not usecache:
             logging.debug("Processing " + apkfilename)
-            thisinfo = {}
-            thisinfo['apkname'] = apkfilename
-            thisinfo['sha256'] = shasum
+            apk = {}
+            apk['apkname'] = apkfilename
+            apk['sha256'] = shasum
             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'] = set()
-            thisinfo['features'] = set()
-            thisinfo['icons_src'] = {}
-            thisinfo['icons'] = {}
+                apk['srcname'] = srcfilename
+            apk['size'] = os.path.getsize(apkfile)
+            apk['permissions'] = set()
+            apk['features'] = set()
+            apk['icons_src'] = {}
+            apk['icons'] = {}
             p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
             if p.returncode != 0:
                 if options.delete_unknown:
@@ -470,51 +492,51 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             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:
+                        apk['id'] = re.match(name_pat, line).group(1)
+                        apk['versioncode'] = int(re.match(vercode_pat, line).group(1))
+                        apk['version'] = re.match(vername_pat, line).group(1)
+                    except Exception as e:
                         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)
+                    apk['name'] = re.match(label_pat, line).group(1)
                     # Keep path to non-dpi icon in case we need it
                     match = re.match(icon_pat_nodpi, line)
                     if match:
-                        thisinfo['icons_src']['-1'] = match.group(1)
+                        apk['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']:
+                    if not apk['name']:
+                        apk['name'] = re.match(label_pat, line).group(1)
+                    if '-1' not in apk['icons_src']:
                         match = re.match(icon_pat_nodpi, line)
                         if match:
-                            thisinfo['icons_src']['-1'] = match.group(1)
+                            apk['icons_src']['-1'] = match.group(1)
                 elif line.startswith("application-icon-"):
                     match = re.match(icon_pat, line)
                     if match:
                         density = match.group(1)
                         path = match.group(2)
-                        thisinfo['icons_src'][density] = path
+                        apk['icons_src'][density] = path
                 elif line.startswith("sdkVersion:"):
                     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)
+                        apk['sdkversion'] = m.group(1)
                 elif line.startswith("maxSdkVersion:"):
-                    thisinfo['maxsdkversion'] = re.match(sdkversion_pat, line).group(1)
+                    apk['maxsdkversion'] = re.match(sdkversion_pat, line).group(1)
                 elif line.startswith("native-code:"):
-                    thisinfo['nativecode'] = []
+                    apk['nativecode'] = []
                     for arch in line[13:].split(' '):
-                        thisinfo['nativecode'].append(arch[1:-1])
+                        apk['nativecode'].append(arch[1:-1])
                 elif line.startswith("uses-permission:"):
                     perm = re.match(string_pat, line).group(1)
                     if perm.startswith("android.permission."):
                         perm = perm[19:]
-                    thisinfo['permissions'].add(perm)
+                    apk['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
@@ -523,11 +545,11 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                             and perm != "android.hardware.screen.landscape":
                         if perm.startswith("android.feature."):
                             perm = perm[16:]
-                        thisinfo['features'].add(perm)
+                        apk['features'].add(perm)
 
-            if 'sdkversion' not in thisinfo:
+            if 'sdkversion' not in apk:
                 logging.warn("No SDK version information found in {0}".format(apkfile))
-                thisinfo['sdkversion'] = 0
+                apk['sdkversion'] = 0
 
             # Check for debuggable apks...
             if common.isApkDebuggable(apkfile, config):
@@ -535,70 +557,84 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
             # Get the signature (or md5 of, to be precise)...
             logging.debug('Getting signature of {0}'.format(apkfile))
-            thisinfo['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
-            if not thisinfo['sig']:
+            apk['sig'] = getsig(os.path.join(os.getcwd(), apkfile))
+            if not apk['sig']:
                 logging.critical("Failed to get apk signature")
                 sys.exit(1)
 
-            apk = zipfile.ZipFile(apkfile, 'r')
+            apkzip = zipfile.ZipFile(apkfile, 'r')
+
+            # if an APK has files newer than the system time, suggest updating
+            # the system clock.  This is useful for offline systems, used for
+            # signing, which do not have another source of clock sync info. It
+            # has to be more than 24 hours newer because ZIP/APK files do not
+            # store timezone info
+            manifest = apkzip.getinfo('AndroidManifest.xml')
+            if manifest.date_time[1] == 0:  # month can't be zero
+                logging.debug('AndroidManifest.xml has no date')
+            else:
+                dt_obj = datetime(*manifest.date_time)
+                checkdt = dt_obj - timedelta(1)
+                if datetime.today() < checkdt:
+                    logging.warn('System clock is older than manifest in: '
+                                 + apkfilename
+                                 + '\nSet clock to that time using:\n'
+                                 + 'sudo date -s "' + str(dt_obj) + '"')
 
             iconfilename = "%s.%s.png" % (
-                thisinfo['id'],
-                thisinfo['versioncode'])
+                apk['id'],
+                apk['versioncode'])
 
             # Extract the icon file...
-            densities = get_densities()
             empty_densities = []
-            for density in densities:
-                if density not in thisinfo['icons_src']:
+            for density in screen_densities:
+                if density not in apk['icons_src']:
                     empty_densities.append(density)
                     continue
-                iconsrc = thisinfo['icons_src'][density]
+                iconsrc = apk['icons_src'][density]
                 icon_dir = get_icon_dir(repodir, density)
                 icondest = os.path.join(icon_dir, iconfilename)
 
                 try:
-                    iconfile = open(icondest, 'wb')
-                    iconfile.write(apk.read(iconsrc))
-                    iconfile.close()
-                    thisinfo['icons'][density] = iconfilename
+                    with open(icondest, 'wb') as f:
+                        f.write(apkzip.read(iconsrc))
+                    apk['icons'][density] = iconfilename
 
                 except:
                     logging.warn("Error retrieving icon file")
-                    del thisinfo['icons'][density]
-                    del thisinfo['icons_src'][density]
+                    del apk['icons'][density]
+                    del apk['icons_src'][density]
                     empty_densities.append(density)
 
-            if '-1' in thisinfo['icons_src']:
-                iconsrc = thisinfo['icons_src']['-1']
+            if '-1' in apk['icons_src']:
+                iconsrc = apk['icons_src']['-1']
                 iconpath = os.path.join(
-                    get_icon_dir(repodir, None), iconfilename)
-                iconfile = open(iconpath, 'wb')
-                iconfile.write(apk.read(iconsrc))
-                iconfile.close()
+                    get_icon_dir(repodir, '0'), iconfilename)
+                with open(iconpath, 'wb') as f:
+                    f.write(apkzip.read(iconsrc))
                 try:
                     im = Image.open(iconpath)
                     dpi = px_to_dpi(im.size[0])
-                    for density in densities:
-                        if density in thisinfo['icons']:
+                    for density in screen_densities:
+                        if density in apk['icons']:
                             break
-                        if density == densities[-1] or dpi >= int(density):
-                            thisinfo['icons'][density] = iconfilename
+                        if density == screen_densities[-1] or dpi >= int(density):
+                            apk['icons'][density] = iconfilename
                             shutil.move(iconpath,
                                         os.path.join(get_icon_dir(repodir, density), iconfilename))
                             empty_densities.remove(density)
                             break
-                except Exception, e:
+                except Exception as e:
                     logging.warn("Failed reading {0} - {1}".format(iconpath, e))
 
-            if thisinfo['icons']:
-                thisinfo['icon'] = iconfilename
+            if apk['icons']:
+                apk['icon'] = iconfilename
 
-            apk.close()
+            apkzip.close()
 
             # First try resizing down to not lose quality
             last_density = None
-            for density in densities:
+            for density in screen_densities:
                 if density not in empty_densities:
                     last_density = density
                     continue
@@ -625,7 +661,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
             # Then just copy from the highest resolution available
             last_density = None
-            for density in reversed(densities):
+            for density in reversed(screen_densities):
                 if density not in empty_densities:
                     last_density = density
                     continue
@@ -640,7 +676,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
                 empty_densities.remove(density)
 
-            for density in densities:
+            for density in screen_densities:
                 icon_dir = get_icon_dir(repodir, density)
                 icondest = os.path.join(icon_dir, iconfilename)
                 resize_icon(icondest, density)
@@ -648,18 +684,23 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             # Copy from icons-mdpi to icons since mdpi is the baseline density
             baseline = os.path.join(get_icon_dir(repodir, '160'), iconfilename)
             if os.path.isfile(baseline):
+                apk['icons']['0'] = iconfilename
                 shutil.copyfile(baseline,
-                                os.path.join(get_icon_dir(repodir, None), iconfilename))
+                                os.path.join(get_icon_dir(repodir, '0'), iconfilename))
 
             # Record in known apks, getting the added date at the same time..
-            added = knownapks.recordapk(thisinfo['apkname'], thisinfo['id'])
+            added = knownapks.recordapk(apk['apkname'], apk['id'])
             if added:
-                thisinfo['added'] = added
+                if use_date_from_apk and manifest.date_time[1] != 0:
+                    added = datetime(*manifest.date_time).timetuple()
+                    logging.debug("Using date from APK")
 
-            apkcache[apkfilename] = thisinfo
+                apk['added'] = added
+
+            apkcache[apkfilename] = apk
             cachechanged = True
 
-        apks.append(thisinfo)
+        apks.append(apk)
 
     return apks, cachechanged
 
@@ -681,11 +722,12 @@ def extract_pubkey():
     if 'repo_pubkey' in config:
         pubkey = unhexlify(config['repo_pubkey'])
     else:
-        p = FDroidPopen(['keytool', '-exportcert',
+        p = FDroidPopen([config['keytool'], '-exportcert',
                          '-alias', config['repo_keyalias'],
                          '-keystore', config['keystore'],
                          '-storepass:file', config['keystorepassfile']]
-                        + config['smartcardoptions'], output=False)
+                        + config['smartcardoptions'],
+                        output=False, stderr_to_stdout=False)
         if p.returncode != 0 or len(p.output) < 20:
             msg = "Failed to get repo pubkey!"
             if config['keystore'] == 'NONE':
@@ -711,15 +753,16 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
     doc = Document()
 
     def addElement(name, value, doc, parent):
-        if not value:
-            return
         el = doc.createElement(name)
         el.appendChild(doc.createTextNode(value))
         parent.appendChild(el)
 
-    def addElementCDATA(name, value, doc, parent):
+    def addElementNonEmpty(name, value, doc, parent):
         if not value:
             return
+        addElement(name, value, doc, parent)
+
+    def addElementCDATA(name, value, doc, parent):
         el = doc.createElement(name)
         el.appendChild(doc.createCDATASection(value))
         parent.appendChild(el)
@@ -729,6 +772,15 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
 
     repoel = doc.createElement("repo")
 
+    mirrorcheckfailed = False
+    for mirror in config.get('mirrors', []):
+        base = os.path.basename(urlparse.urlparse(mirror).path.rstrip('/'))
+        if config.get('nonstandardwebroot') is not True and base != 'fdroid':
+            logging.error("mirror '" + mirror + "' does not end with 'fdroid'!")
+            mirrorcheckfailed = True
+    if mirrorcheckfailed:
+        sys.exit(1)
+
     if archive:
         repoel.setAttribute("name", config['archive_name'])
         if config['repo_maxage'] != 0:
@@ -736,6 +788,9 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
         repoel.setAttribute("icon", os.path.basename(config['archive_icon']))
         repoel.setAttribute("url", config['archive_url'])
         addElement('description', config['archive_description'], doc, repoel)
+        urlbasepath = os.path.basename(urlparse.urlparse(config['archive_url']).path)
+        for mirror in config.get('mirrors', []):
+            addElement('mirror', urlparse.urljoin(mirror, urlbasepath), doc, repoel)
 
     else:
         repoel.setAttribute("name", config['repo_name'])
@@ -744,8 +799,11 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
         repoel.setAttribute("icon", os.path.basename(config['repo_icon']))
         repoel.setAttribute("url", config['repo_url'])
         addElement('description', config['repo_description'], doc, repoel)
+        urlbasepath = os.path.basename(urlparse.urlparse(config['repo_url']).path)
+        for mirror in config.get('mirrors', []):
+            addElement('mirror', urlparse.urljoin(mirror, urlbasepath), doc, repoel)
 
-    repoel.setAttribute("version", "13")
+    repoel.setAttribute("version", "15")
     repoel.setAttribute("timestamp", str(int(time.time())))
 
     nosigningkey = False
@@ -776,7 +834,7 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
     for appid in sortedids:
         app = apps[appid]
 
-        if app['Disabled'] is not None:
+        if app.Disabled is not None:
             continue
 
         # Get a list of the apks for this app...
@@ -789,61 +847,59 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
             continue
 
         apel = doc.createElement("application")
-        apel.setAttribute("id", app['id'])
+        apel.setAttribute("id", app.id)
         root.appendChild(apel)
 
-        addElement('id', app['id'], doc, apel)
-        if 'added' in app:
-            addElement('added', time.strftime('%Y-%m-%d', app['added']), doc, apel)
-        if 'lastupdated' in app:
-            addElement('lastupdated', time.strftime('%Y-%m-%d', app['lastupdated']), doc, apel)
-        addElement('name', app['Name'], doc, apel)
-        addElement('summary', app['Summary'], doc, apel)
-        addElement('icon', app['icon'], doc, apel)
+        addElement('id', app.id, doc, apel)
+        if app.added:
+            addElement('added', time.strftime('%Y-%m-%d', app.added), doc, apel)
+        if app.lastupdated:
+            addElement('lastupdated', time.strftime('%Y-%m-%d', app.lastupdated), doc, apel)
+        addElement('name', app.Name, doc, apel)
+        addElement('summary', app.Summary, doc, apel)
+        if app.icon:
+            addElement('icon', app.icon, doc, apel)
 
         def linkres(appid):
             if appid in apps:
-                return ("fdroid.app:" + appid, apps[appid]['Name'])
+                return ("fdroid.app:" + appid, apps[appid].Name)
             raise MetaDataException("Cannot resolve app id " + appid)
 
         addElement('desc',
-                   metadata.description_html(app['Description'], linkres),
+                   metadata.description_html(app.Description, linkres),
                    doc, apel)
-        addElement('license', app['License'], doc, apel)
-        if 'Categories' in app and app['Categories']:
-            addElement('categories', ','.join(app["Categories"]), doc, apel)
+        addElement('license', app.License, doc, apel)
+        if app.Categories:
+            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', 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)
-        addElement('changelog', app['Changelog'], doc, apel)
-        addElement('donate', app['Donate'], doc, apel)
-        addElement('bitcoin', app['Bitcoin'], doc, apel)
-        addElement('litecoin', app['Litecoin'], doc, apel)
-        addElement('dogecoin', app['Dogecoin'], doc, apel)
-        addElement('flattr', app['FlattrID'], doc, apel)
+            addElement('category', app.Categories[0], doc, apel)
+        addElement('web', app.WebSite, doc, apel)
+        addElement('source', app.SourceCode, doc, apel)
+        addElement('tracker', app.IssueTracker, doc, apel)
+        addElementNonEmpty('changelog', app.Changelog, doc, apel)
+        addElementNonEmpty('author', app.AuthorName, doc, apel)
+        addElementNonEmpty('email', app.AuthorEmail, doc, apel)
+        addElementNonEmpty('donate', app.Donate, doc, apel)
+        addElementNonEmpty('bitcoin', app.Bitcoin, doc, apel)
+        addElementNonEmpty('litecoin', app.Litecoin, doc, apel)
+        addElementNonEmpty('flattr', app.FlattrID, doc, apel)
 
         # These elements actually refer to the current version (i.e. which
         # one is recommended. They are historically mis-named, and need
         # changing, but stay like this for now to support existing clients.
-        addElement('marketversion', app['Current Version'], doc, apel)
-        addElement('marketvercode', app['Current Version Code'], doc, apel)
-
-        af = app['AntiFeatures'].split(',')
-        # TODO: Temporarily not including UpstreamNonFree in the index,
-        # because current F-Droid clients do not understand it, and also
-        # look ugly when they encounter an unknown antifeature. This
-        # filtering can be removed in time...
-        if 'UpstreamNonFree' in af:
-            af.remove('UpstreamNonFree')
-        if af:
-            addElement('antifeatures', ','.join(af), doc, apel)
-        pv = app['Provides'].split(',')
-        addElement('provides', ','.join(pv), doc, apel)
-        if app['Requires Root']:
+        addElement('marketversion', app.CurrentVersion, doc, apel)
+        addElement('marketvercode', app.CurrentVersionCode, doc, apel)
+
+        if app.AntiFeatures:
+            af = app.AntiFeatures
+            if af:
+                addElementNonEmpty('antifeatures', ','.join(af), doc, apel)
+        if app.Provides:
+            pv = app.Provides.split(',')
+            addElementNonEmpty('provides', ','.join(pv), doc, apel)
+        if app.RequiresRoot:
             addElement('requirements', 'root', doc, apel)
 
         # Sort the apk list into version order, just so the web site
@@ -863,7 +919,7 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
             # find the APK for the "Current Version"
             if current_version_code < apk['versioncode']:
                 current_version_code = apk['versioncode']
-            if current_version_code < int(app['Current Version Code']):
+            if current_version_code < int(app.CurrentVersionCode):
                 current_version_file = apk['apkname']
 
             apkel = doc.createElement("package")
@@ -887,19 +943,19 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
                 addElement('maxsdkver', str(apk['maxsdkversion']), doc, apkel)
             if 'added' in apk:
                 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
-            addElement('permissions', ','.join(apk['permissions']), doc, apkel)
+            addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
             if 'nativecode' in apk:
                 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
-            addElement('features', ','.join(apk['features']), doc, apkel)
+            addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
 
         if current_version_file is not None \
                 and config['make_current_version_link'] \
                 and repodir == 'repo':  # only create these
-            sanitized_name = re.sub('''[ '"&%?+=/]''', '',
-                                    app[config['current_version_name_source']])
+            namefield = config['current_version_name_source']
+            sanitized_name = re.sub('''[ '"&%?+=/]''', '', app.get_field(namefield))
             apklinkname = sanitized_name + '.apk'
             current_version_path = os.path.join(repodir, current_version_file)
-            if os.path.exists(apklinkname):
+            if os.path.islink(apklinkname):
                 os.remove(apklinkname)
             os.symlink(current_version_path, apklinkname)
             # also symlink gpg signature, if it exists
@@ -907,17 +963,17 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
                 sigfile_path = current_version_path + extension
                 if os.path.exists(sigfile_path):
                     siglinkname = apklinkname + extension
-                    if os.path.exists(siglinkname):
+                    if os.path.islink(siglinkname):
                         os.remove(siglinkname)
                     os.symlink(sigfile_path, siglinkname)
 
-    of = open(os.path.join(repodir, 'index.xml'), 'wb')
     if options.pretty:
         output = doc.toprettyxml()
     else:
         output = doc.toxml()
-    of.write(output)
-    of.close()
+
+    with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
+        f.write(output)
 
     if 'repo_keyalias' in config:
 
@@ -941,9 +997,9 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
             if os.path.exists(signed):
                 os.remove(signed)
         else:
-            args = ['jarsigner', '-keystore', config['keystore'],
+            args = [config['jarsigner'], '-keystore', config['keystore'],
                     '-storepass:file', config['keystorepassfile'],
-                    '-digestalg', 'SHA1', '-sigalg', 'MD5withRSA',
+                    '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
                     signed, config['repo_keyalias']]
             if config['keystore'] == 'NONE':
                 args += config['smartcardoptions']
@@ -963,45 +1019,92 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
     catdata = ''
     for cat in categories:
         catdata += cat + '\n'
-    f = open(os.path.join(repodir, 'categories.txt'), 'w')
-    f.write(catdata)
-    f.close()
+    with open(os.path.join(repodir, 'categories.txt'), 'w') as f:
+        f.write(catdata)
 
 
 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
 
     for appid, app in apps.iteritems():
 
-        # Get a list of the apks for this app...
-        apklist = []
-        for apk in apks:
-            if apk['id'] == appid:
-                apklist.append(apk)
-
-        # Sort the apk list into version order...
-        apklist = sorted(apklist, key=lambda apk: apk['versioncode'], reverse=True)
-
-        if app['Archive Policy']:
-            keepversions = int(app['Archive Policy'][:-9])
+        if app.ArchivePolicy:
+            keepversions = int(app.ArchivePolicy[:-9])
         else:
             keepversions = defaultkeepversions
 
-        if len(apklist) > keepversions:
+        def filter_apk_list_sorted(apk_list):
+            res = []
+            for apk in apk_list:
+                if apk['id'] == appid:
+                    res.append(apk)
+
+            # Sort the apk list by version code. First is highest/newest.
+            return sorted(res, key=lambda apk: apk['versioncode'], reverse=True)
+
+        def move_file(from_dir, to_dir, filename, ignore_missing):
+            from_path = os.path.join(from_dir, filename)
+            if ignore_missing and not os.path.exists(from_path):
+                return
+            to_path = os.path.join(to_dir, filename)
+            shutil.move(from_path, to_path)
+
+        if len(apks) > keepversions:
+            apklist = filter_apk_list_sorted(apks)
+            # Move back the ones we don't want.
             for apk in apklist[keepversions:]:
                 logging.info("Moving " + apk['apkname'] + " to archive")
-                shutil.move(os.path.join(repodir, apk['apkname']),
-                            os.path.join(archivedir, apk['apkname']))
+                move_file(repodir, archivedir, apk['apkname'], False)
+                move_file(repodir, archivedir, apk['apkname'] + '.asc', True)
+                for density in all_screen_densities:
+                    repo_icon_dir = get_icon_dir(repodir, density)
+                    archive_icon_dir = get_icon_dir(archivedir, density)
+                    if density not in apk['icons']:
+                        continue
+                    move_file(repo_icon_dir, archive_icon_dir, apk['icons'][density], True)
                 if 'srcname' in apk:
-                    shutil.move(os.path.join(repodir, 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))
-
+                    move_file(repodir, archivedir, apk['srcname'], False)
                 archapks.append(apk)
                 apks.remove(apk)
+        elif len(apks) < keepversions and len(archapks) > 0:
+            required = keepversions - len(apks)
+            archapklist = filter_apk_list_sorted(archapks)
+            # Move forward the ones we want again.
+            for apk in archapklist[:required]:
+                logging.info("Moving " + apk['apkname'] + " from archive")
+                move_file(archivedir, repodir, apk['apkname'], False)
+                move_file(archivedir, repodir, apk['apkname'] + '.asc', True)
+                for density in all_screen_densities:
+                    repo_icon_dir = get_icon_dir(repodir, density)
+                    archive_icon_dir = get_icon_dir(archivedir, density)
+                    if density not in apk['icons']:
+                        continue
+                    move_file(archive_icon_dir, repo_icon_dir, apk['icons'][density], True)
+                if 'srcname' in apk:
+                    move_file(archivedir, repodir, apk['srcname'], False)
+                archapks.remove(apk)
+                apks.append(apk)
+
+
+def add_apks_to_per_app_repos(repodir, apks):
+    apks_per_app = dict()
+    for apk in apks:
+        apk['per_app_dir'] = os.path.join(apk['id'], 'fdroid')
+        apk['per_app_repo'] = os.path.join(apk['per_app_dir'], 'repo')
+        apk['per_app_icons'] = os.path.join(apk['per_app_repo'], 'icons')
+        apks_per_app[apk['id']] = apk
+
+        if not os.path.exists(apk['per_app_icons']):
+            logging.info('Adding new repo for only ' + apk['id'])
+            os.makedirs(apk['per_app_icons'])
+
+        apkpath = os.path.join(repodir, apk['apkname'])
+        shutil.copy(apkpath, apk['per_app_repo'])
+        apksigpath = apkpath + '.sig'
+        if os.path.exists(apksigpath):
+            shutil.copy(apksigpath, apk['per_app_repo'])
+        apkascpath = apkpath + '.asc'
+        if os.path.exists(apkascpath):
+            shutil.copy(apkascpath, apk['per_app_repo'])
 
 
 config = None
@@ -1013,38 +1116,41 @@ def main():
     global config, options
 
     # Parse command line...
-    parser = OptionParser()
-    parser.add_option("--create-key", action="store_true", default=False,
-                      help="Create a repo signing key in a keystore")
-    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="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",
-                      help="Interactively ask about things that need updating.")
-    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")
-    parser.add_option("-w", "--wiki", default=False, action="store_true",
-                      help="Update the wiki")
-    parser.add_option("", "--pretty", action="store_true", default=False,
-                      help="Produce human-readable index.xml")
-    parser.add_option("--clean", action="store_true", default=False,
-                      help="Clean update - don't uses caches, reprocess all apks")
-    parser.add_option("--nosign", action="store_true", default=False,
-                      help="When configured for signed indexes, create only unsigned indexes at this stage")
-    (options, args) = parser.parse_args()
+    parser = ArgumentParser()
+    common.setup_global_opts(parser)
+    parser.add_argument("--create-key", action="store_true", default=False,
+                        help="Create a repo signing key in a keystore")
+    parser.add_argument("-c", "--create-metadata", action="store_true", default=False,
+                        help="Create skeleton metadata files that are missing")
+    parser.add_argument("--delete-unknown", action="store_true", default=False,
+                        help="Delete APKs without metadata from the repo")
+    parser.add_argument("-b", "--buildreport", action="store_true", default=False,
+                        help="Report on build data status")
+    parser.add_argument("-i", "--interactive", default=False, action="store_true",
+                        help="Interactively ask about things that need updating.")
+    parser.add_argument("-I", "--icons", action="store_true", default=False,
+                        help="Resize all the icons exceeding the max pixel size and exit")
+    parser.add_argument("-e", "--editor", default="/etc/alternatives/editor",
+                        help="Specify editor to use in interactive mode. Default " +
+                        "is /etc/alternatives/editor")
+    parser.add_argument("-w", "--wiki", default=False, action="store_true",
+                        help="Update the wiki")
+    parser.add_argument("--pretty", action="store_true", default=False,
+                        help="Produce human-readable index.xml")
+    parser.add_argument("--clean", action="store_true", default=False,
+                        help="Clean update - don't uses caches, reprocess all apks")
+    parser.add_argument("--nosign", action="store_true", default=False,
+                        help="When configured for signed indexes, create only unsigned indexes at this stage")
+    parser.add_argument("--use-date-from-apk", action="store_true", default=False,
+                        help="Use date from apk instead of current time for newly added apks")
+    options = parser.parse_args()
 
     config = common.read_config(options)
 
+    if not ('jarsigner' in config and 'keytool' in config):
+        logging.critical('Java JDK not found! Install in standard location or set java_paths!')
+        sys.exit(1)
+
     repodirs = ['repo']
     if config['archive_older'] != 0:
         repodirs.append('archive')
@@ -1094,7 +1200,7 @@ def main():
     # Generate a list of categories...
     categories = set()
     for app in apps.itervalues():
-        categories.update(app['Categories'])
+        categories.update(app.Categories)
 
     # Read known apks data (will be updated and written back when we've finished)
     knownapks = common.KnownApks()
@@ -1107,14 +1213,11 @@ def main():
             apkcache = pickle.load(cf)
     else:
         apkcache = {}
-    cachechanged = False
 
     delete_disabled_builds(apps, apkcache, repodirs)
 
     # Scan all apks in the main repo
-    apks, cc = scan_apks(apps, apkcache, repodirs[0], knownapks)
-    if cc:
-        cachechanged = True
+    apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks, options.use_date_from_apk)
 
     # Generate warnings for apk's with no metadata (or create skeleton
     # metadata files, if requested on the command line)
@@ -1156,7 +1259,7 @@ def main():
 
     # Scan the archive repo for apks as well
     if len(repodirs) > 1:
-        archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks)
+        archapks, cc = scan_apks(apps, apkcache, repodirs[1], knownapks, options.use_date_from_apk)
         if cc:
             cachechanged = True
     else:
@@ -1168,8 +1271,6 @@ def main():
     # same time.
     for appid, app in apps.iteritems():
         bestver = 0
-        added = None
-        lastupdated = None
         for apk in apks + archapks:
             if apk['id'] == appid:
                 if apk['versioncode'] > bestver:
@@ -1177,34 +1278,46 @@ def main():
                     bestapk = apk
 
                 if 'added' in apk:
-                    if not added or apk['added'] < added:
-                        added = apk['added']
-                    if not lastupdated or apk['added'] > lastupdated:
-                        lastupdated = apk['added']
+                    if not app.added or apk['added'] < app.added:
+                        app.added = apk['added']
+                    if not app.lastupdated or apk['added'] > app.lastupdated:
+                        app.lastupdated = apk['added']
 
-        if added:
-            app['added'] = added
-        else:
-            logging.warn("Don't know when " + appid + " was added")
-        if lastupdated:
-            app['lastupdated'] = lastupdated
-        else:
-            logging.warn("Don't know when " + appid + " was last updated")
+        if not app.added:
+            logging.debug("Don't know when " + appid + " was added")
+        if not app.lastupdated:
+            logging.debug("Don't know when " + appid + " was last updated")
 
         if bestver == 0:
-            if app['Name'] is None:
-                app['Name'] = app['Auto Name'] or appid
-            app['icon'] = None
-            logging.warn("Application " + appid + " has no packages")
+            if app.Name is None:
+                app.Name = app.AutoName or appid
+            app.icon = None
+            logging.debug("Application " + appid + " has no packages")
         else:
-            if app['Name'] is None:
-                app['Name'] = bestapk['name']
-            app['icon'] = bestapk['icon'] if 'icon' in bestapk else None
+            if app.Name is None:
+                app.Name = bestapk['name']
+            app.icon = bestapk['icon'] if 'icon' in bestapk else None
+            if app.CurrentVersionCode is None:
+                app.CurrentVersionCode = str(bestver)
 
     # 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!)
-    sortedids = sorted(apps.iterkeys(), key=lambda appid: apps[appid]['Name'].upper())
+    sortedids = sorted(apps.iterkeys(), key=lambda appid: apps[appid].Name.upper())
+
+    # APKs are placed into multiple repos based on the app package, providing
+    # per-app subscription feeds for nightly builds and things like it
+    if config['per_app_repos']:
+        add_apks_to_per_app_repos(repodirs[0], apks)
+        for appid, app in apps.iteritems():
+            repodir = os.path.join(appid, 'fdroid', 'repo')
+            appdict = dict()
+            appdict[appid] = app
+            if os.path.isdir(repodir):
+                make_index(appdict, [appid], apks, repodir, False, categories)
+            else:
+                logging.info('Skipping index generation for ' + appid)
+        return
 
     if len(repodirs) > 1:
         archive_old_apks(apps, apks, archapks, repodirs[0], repodirs[1], config['archive_older'])
@@ -1229,13 +1342,12 @@ def main():
                 appid = line.rstrip()
                 data += appid + "\t"
                 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()
+                data += app.Name + "\t"
+                if app.icon is not None:
+                    data += app.icon + "\t"
+                data += app.License + "\n"
+            with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w') as f:
+                f.write(data)
 
     if cachechanged:
         with open(apkcachefile, 'wb') as cf: