chiark / gitweb /
Parse maxSdkVersion and -23 permission tags
[fdroidserver.git] / fdroidserver / update.py
index 3a01a7c21cb2db097621d45eca9fd828053c4fbf..f8646e2a258932cd4ce17d7cdd724afd27aa54e1 100644 (file)
@@ -1,5 +1,4 @@
-#!/usr/bin/env python2
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
 #
 # update.py - part of the FDroid server tools
 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
@@ -27,28 +26,35 @@ import socket
 import zipfile
 import hashlib
 import pickle
+import urllib.parse
 from datetime import datetime, timedelta
 from xml.dom.minidom import Document
 from argparse import ArgumentParser
 import time
+
+import collections
 from pyasn1.error import PyAsn1Error
 from pyasn1.codec.der import decoder, encoder
 from pyasn1_modules import rfc2315
-from hashlib import md5
 from binascii import hexlify, unhexlify
 
 from PIL import Image
 import logging
 
-import common
-import metadata
-from common import FDroidPopen, SdkToolsPopen
-from metadata import MetaDataException
+from . import common
+from . import metadata
+from .common import FDroidPopen, FDroidPopenBytes, SdkToolsPopen
+from .metadata import MetaDataException
+
+METADATA_VERSION = 17
 
 screen_densities = ['640', '480', '320', '240', '160', '120']
 
 all_screen_densities = ['0'] + screen_densities
 
+UsesPermission = collections.namedtuple('UsesPermission', ['name', 'maxSdkVersion'])
+UsesPermissionSdk23 = collections.namedtuple('UsesPermissionSdk23', ['name', 'maxSdkVersion'])
+
 
 def dpi_to_px(density):
     return (int(density) * 48) / 160
@@ -103,7 +109,7 @@ def update_wiki(apps, sortedids, apks):
             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}}\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|license=%s|root=%s|author=%s|email=%s}}\n' % (
             appid,
             app.Name,
             time.strftime('%Y-%m-%d', app.added) if app.added else '',
@@ -117,7 +123,9 @@ def update_wiki(apps, sortedids, apks):
             app.Bitcoin,
             app.Litecoin,
             app.License,
-            requiresroot)
+            requiresroot,
+            app.AuthorName,
+            app.AuthorEmail)
 
         if app.Provides:
             wikidata += "This app provides: %s" % ', '.join(app.Summary.split(','))
@@ -205,7 +213,7 @@ def update_wiki(apps, sortedids, apks):
         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"
+            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.UpdateCheckMode != "Static":
@@ -289,7 +297,7 @@ def delete_disabled_builds(apps, apkcache, repodirs):
     :param apkcache: current apk cache information
     :param repodirs: the repo directories to process
     """
-    for appid, app in apps.iteritems():
+    for appid, app in apps.items():
         for build in app.builds:
             if not build.disable:
                 continue
@@ -320,8 +328,10 @@ def resize_icon(iconpath, density):
     if not os.path.isfile(iconpath):
         return
 
+    fp = None
     try:
-        im = Image.open(iconpath)
+        fp = open(iconpath, 'rb')
+        im = Image.open(fp)
         size = dpi_to_px(density)
 
         if any(length > size for length in im.size):
@@ -331,9 +341,13 @@ 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))
 
+    finally:
+        if fp:
+            fp.close()
+
 
 def resize_all_icons(repodirs):
     """Resize all icons that exceed the max size
@@ -365,7 +379,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!")
@@ -399,10 +413,94 @@ def getsig(apkpath):
 
     cert_encoded = encoder.encode(certificates)[4:]
 
-    return md5(cert_encoded.encode('hex')).hexdigest()
+    return hashlib.md5(hexlify(cert_encoded)).hexdigest()
+
+
+def get_icon_bytes(apkzip, iconsrc):
+    '''ZIP has no official encoding, UTF-* and CP437 are defacto'''
+    try:
+        return apkzip.read(iconsrc)
+    except KeyError:
+        return apkzip.read(iconsrc.encode('utf-8').decode('cp437'))
+
+
+def sha256sum(filename):
+    '''Calculate the sha256 of the given file'''
+    sha = hashlib.sha256()
+    with open(filename, 'rb') as f:
+        while True:
+            t = f.read(16384)
+            if len(t) == 0:
+                break
+            sha.update(t)
+    return sha.hexdigest()
+
+
+def insert_obbs(repodir, apps, apks):
+    """Scans the .obb files in a given repo directory and adds them to the
+    relevant APK instances.  OBB files have versionCodes like APK
+    files, and they are loosely associated.  If there is an OBB file
+    present, then any APK with the same or higher versionCode will use
+    that OBB file.  There are two OBB types: main and patch, each APK
+    can only have only have one of each.
 
+    https://developer.android.com/google/play/expansion-files.html
+
+    :param repodir: repo directory to scan
+    :param apps: list of current, valid apps
+    :param apks: current information on all APKs
+
+    """
+
+    def obbWarnDelete(f, msg):
+        logging.warning(msg + f)
+        if options.delete_unknown:
+            logging.error("Deleting unknown file: " + f)
+            os.remove(f)
+
+    obbs = []
+    java_Integer_MIN_VALUE = -pow(2, 31)
+    for f in glob.glob(os.path.join(repodir, '*.obb')):
+        obbfile = os.path.basename(f)
+        # obbfile looks like: [main|patch].<expansion-version>.<package-name>.obb
+        chunks = obbfile.split('.')
+        if chunks[0] != 'main' and chunks[0] != 'patch':
+            obbWarnDelete(f, 'OBB filename must start with "main." or "patch.": ')
+            continue
+        if not re.match(r'^-?[0-9]+$', chunks[1]):
+            obbWarnDelete('The OBB version code must come after "' + chunks[0] + '.": ')
+            continue
+        versioncode = int(chunks[1])
+        packagename = ".".join(chunks[2:-1])
+
+        highestVersionCode = java_Integer_MIN_VALUE
+        if packagename not in apps.keys():
+            obbWarnDelete(f, "OBB's packagename does not match a supported APK: ")
+            continue
+        for apk in apks:
+            if packagename == apk['id'] and apk['versioncode'] > highestVersionCode:
+                highestVersionCode = apk['versioncode']
+        if versioncode > highestVersionCode:
+            obbWarnDelete(f, 'OBB file has newer versioncode(' + str(versioncode)
+                          + ') than any APK: ')
+            continue
+        obbsha256 = sha256sum(f)
+        obbs.append((packagename, versioncode, obbfile, obbsha256))
 
-def scan_apks(apps, apkcache, repodir, knownapks):
+    for apk in apks:
+        for (packagename, versioncode, obbfile, obbsha256) in sorted(obbs, reverse=True):
+            if versioncode <= apk['versioncode'] and packagename == apk['id']:
+                if obbfile.startswith('main.') and 'obbMainFile' not in apk:
+                    apk['obbMainFile'] = obbfile
+                    apk['obbMainFileSha256'] = obbsha256
+                elif obbfile.startswith('patch.') and 'obbPatchFile' not in apk:
+                    apk['obbPatchFile'] = obbfile
+                    apk['obbPatchFileSha256'] = obbsha256
+            if 'obbMainFile' in apk and 'obbPatchFile' in apk:
+                break
+
+
+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.
@@ -411,6 +509,8 @@ 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.
     """
@@ -433,7 +533,8 @@ def scan_apks(apps, apkcache, repodir, knownapks):
     icon_pat = re.compile(".*application-icon-([0-9]+):'([^']+?)'.*")
     icon_pat_nodpi = re.compile(".*icon='([^']+?)'.*")
     sdkversion_pat = re.compile(".*'([0-9]*)'.*")
-    string_pat = re.compile(".*'([^']*)'.*")
+    permission_pat = re.compile(".*(name='(?P<name>.*?)')(.*maxSdkVersion='(?P<maxSdkVersion>.*?)')?.*")
+    feature_pat = re.compile(".*name='([^']*)'.*")
     for apkfile in glob.glob(os.path.join(repodir, '*.apk')):
 
         apkfilename = apkfile[len(repodir) + 1:]
@@ -441,15 +542,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             logging.critical("Spaces in filenames are not allowed.")
             sys.exit(1)
 
-        # Calculate the sha256...
-        sha = hashlib.sha256()
-        with open(apkfile, 'rb') as f:
-            while True:
-                t = f.read(16384)
-                if len(t) == 0:
-                    break
-                sha.update(t)
-            shasum = sha.hexdigest()
+        shasum = sha256sum(apkfile)
 
         usecache = False
         if apkfilename in apkcache:
@@ -469,7 +562,8 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             if os.path.exists(os.path.join(repodir, srcfilename)):
                 apk['srcname'] = srcfilename
             apk['size'] = os.path.getsize(apkfile)
-            apk['permissions'] = set()
+            apk['uses-permission'] = set()
+            apk['uses-permission-sdk-23'] = set()
             apk['features'] = set()
             apk['icons_src'] = {}
             apk['icons'] = {}
@@ -490,7 +584,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                         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, e:
+                    except Exception as e:
                         logging.error("Package matching failed: " + str(e))
                         logging.info("Line was: " + line)
                         sys.exit(1)
@@ -520,35 +614,59 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                         logging.error(line.replace('sdkVersion:', '')
                                       + ' is not a valid minSdkVersion!')
                     else:
-                        apk['sdkversion'] = m.group(1)
+                        apk['minSdkVersion'] = m.group(1)
+                        # if target not set, default to min
+                        if 'targetSdkVersion' not in apk:
+                            apk['targetSdkVersion'] = m.group(1)
+                elif line.startswith("targetSdkVersion:"):
+                    m = re.match(sdkversion_pat, line)
+                    if m is None:
+                        logging.error(line.replace('targetSdkVersion:', '')
+                                      + ' is not a valid targetSdkVersion!')
+                    else:
+                        apk['targetSdkVersion'] = m.group(1)
                 elif line.startswith("maxSdkVersion:"):
-                    apk['maxsdkversion'] = re.match(sdkversion_pat, line).group(1)
+                    apk['maxSdkVersion'] = re.match(sdkversion_pat, line).group(1)
                 elif line.startswith("native-code:"):
                     apk['nativecode'] = []
                     for arch in line[13:].split(' '):
                         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:]
-                    apk['permissions'].add(perm)
-                elif line.startswith("uses-feature:"):
-                    perm = re.match(string_pat, line).group(1)
+                elif line.startswith('uses-permission:'):
+                    perm_match = re.match(permission_pat, line).groupdict()
+
+                    permission = UsesPermission(
+                        perm_match['name'],
+                        perm_match['maxSdkVersion']
+                    )
+
+                    apk['uses-permission'].add(permission)
+                elif line.startswith('uses-permission-sdk-23:'):
+                    perm_match = re.match(permission_pat, line).groupdict()
+
+                    permission_sdk_23 = UsesPermissionSdk23(
+                        perm_match['name'],
+                        perm_match['maxSdkVersion']
+                    )
+
+                    apk['uses-permission-sdk-23'].add(permission_sdk_23)
+
+                elif line.startswith('uses-feature:'):
+                    feature = re.match(feature_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":
-                        if perm.startswith("android.feature."):
-                            perm = perm[16:]
-                        apk['features'].add(perm)
+                    if feature != "android.hardware.screen.portrait" \
+                            and feature != "android.hardware.screen.landscape":
+                        if feature.startswith("android.feature."):
+                            feature = feature[16:]
+                        apk['features'].add(feature)
 
-            if 'sdkversion' not in apk:
+            if 'minSdkVersion' not in apk:
                 logging.warn("No SDK version information found in {0}".format(apkfile))
-                apk['sdkversion'] = 0
+                apk['minSdkVersion'] = 1
 
             # Check for debuggable apks...
             if common.isApkDebuggable(apkfile, config):
-                logging.warn('{0} is set to android:debuggable="true"'.format(apkfile))
+                logging.warning('{0} is set to android:debuggable="true"'.format(apkfile))
 
             # Get the signature (or md5 of, to be precise)...
             logging.debug('Getting signature of {0}'.format(apkfile))
@@ -565,12 +683,16 @@ def scan_apks(apps, apkcache, repodir, knownapks):
             # has to be more than 24 hours newer because ZIP/APK files do not
             # store timezone info
             manifest = apkzip.getinfo('AndroidManifest.xml')
-            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) + '"')
+            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" % (
                 apk['id'],
@@ -588,7 +710,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
 
                 try:
                     with open(icondest, 'wb') as f:
-                        f.write(apkzip.read(iconsrc))
+                        f.write(get_icon_bytes(apkzip, iconsrc))
                     apk['icons'][density] = iconfilename
 
                 except:
@@ -602,7 +724,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                 iconpath = os.path.join(
                     get_icon_dir(repodir, '0'), iconfilename)
                 with open(iconpath, 'wb') as f:
-                    f.write(apkzip.read(iconsrc))
+                    f.write(get_icon_bytes(apkzip, iconsrc))
                 try:
                     im = Image.open(iconpath)
                     dpi = px_to_dpi(im.size[0])
@@ -615,7 +737,7 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                                         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 apk['icons']:
@@ -638,17 +760,21 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                     get_icon_dir(repodir, last_density), iconfilename)
                 iconpath = os.path.join(
                     get_icon_dir(repodir, density), iconfilename)
+                fp = None
                 try:
-                    im = Image.open(last_iconpath)
-                except:
-                    logging.warn("Invalid image file at %s" % last_iconpath)
-                    continue
+                    fp = open(last_iconpath, 'rb')
+                    im = Image.open(fp)
 
-                size = dpi_to_px(density)
+                    size = dpi_to_px(density)
 
-                im.thumbnail((size, size), Image.ANTIALIAS)
-                im.save(iconpath, "PNG")
-                empty_densities.remove(density)
+                    im.thumbnail((size, size), Image.ANTIALIAS)
+                    im.save(iconpath, "PNG")
+                    empty_densities.remove(density)
+                except:
+                    logging.warning("Invalid image file at %s" % last_iconpath)
+                finally:
+                    if fp:
+                        fp.close()
 
             # Then just copy from the highest resolution available
             last_density = None
@@ -679,8 +805,13 @@ def scan_apks(apps, apkcache, repodir, knownapks):
                 shutil.copyfile(baseline,
                                 os.path.join(get_icon_dir(repodir, '0'), iconfilename))
 
+            if use_date_from_apk and manifest.date_time[1] != 0:
+                default_date_param = datetime(*manifest.date_time).utctimetuple()
+            else:
+                default_date_param = None
+
             # Record in known apks, getting the added date at the same time..
-            added = knownapks.recordapk(apk['apkname'], apk['id'])
+            added = knownapks.recordapk(apk['apkname'], apk['id'], default_date=default_date_param)
             if added:
                 apk['added'] = added
 
@@ -700,7 +831,7 @@ repo_pubkey_fingerprint = None
 def cert_fingerprint(data):
     digest = hashlib.sha256(data).digest()
     ret = []
-    ret.append(' '.join("%02X" % ord(b) for b in digest))
+    ret.append(' '.join("%02X" % b for b in bytearray(digest)))
     return " ".join(ret)
 
 
@@ -709,11 +840,12 @@ def extract_pubkey():
     if 'repo_pubkey' in config:
         pubkey = unhexlify(config['repo_pubkey'])
     else:
-        p = FDroidPopen(['keytool', '-exportcert',
-                         '-alias', config['repo_keyalias'],
-                         '-keystore', config['keystore'],
-                         '-storepass:file', config['keystorepassfile']]
-                        + config['smartcardoptions'], output=False)
+        p = FDroidPopenBytes([config['keytool'], '-exportcert',
+                              '-alias', config['repo_keyalias'],
+                              '-keystore', config['keystore'],
+                              '-storepass:file', config['keystorepassfile']]
+                             + 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':
@@ -758,6 +890,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(urllib.parse.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:
@@ -765,6 +906,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(urllib.parse.urlparse(config['archive_url']).path)
+        for mirror in config.get('mirrors', []):
+            addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
 
     else:
         repoel.setAttribute("name", config['repo_name'])
@@ -773,8 +917,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(urllib.parse.urlparse(config['repo_url']).path)
+        for mirror in config.get('mirrors', []):
+            addElement('mirror', urllib.parse.urljoin(mirror, urlbasepath), doc, repoel)
 
-    repoel.setAttribute("version", "14")
+    repoel.setAttribute("version", str(METADATA_VERSION))
     repoel.setAttribute("timestamp", str(int(time.time())))
 
     nosigningkey = False
@@ -799,7 +946,7 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
             logging.warning("\tfdroid update --create-key")
             sys.exit(1)
 
-    repoel.setAttribute("pubkey", extract_pubkey())
+    repoel.setAttribute("pubkey", extract_pubkey().decode('utf-8'))
     root.appendChild(repoel)
 
     for appid in sortedids:
@@ -850,6 +997,8 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
         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)
@@ -907,12 +1056,39 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
                 apkel.appendChild(hashel)
             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)
+            addElement('sdkver', str(apk['minSdkVersion']), doc, apkel)
+            if 'targetSdkVersion' in apk:
+                addElement('targetSdkVersion', str(apk['targetSdkVersion']), doc, apkel)
+            if 'maxSdkVersion' in apk:
+                addElement('maxsdkver', str(apk['maxSdkVersion']), doc, apkel)
+            addElementNonEmpty('obbMainFile', apk.get('obbMainFile'), doc, apkel)
+            addElementNonEmpty('obbMainFileSha256', apk.get('obbMainFileSha256'), doc, apkel)
+            addElementNonEmpty('obbPatchFile', apk.get('obbPatchFile'), doc, apkel)
+            addElementNonEmpty('obbPatchFileSha256', apk.get('obbPatchFileSha256'), doc, apkel)
             if 'added' in apk:
                 addElement('added', time.strftime('%Y-%m-%d', apk['added']), doc, apkel)
-            addElementNonEmpty('permissions', ','.join(apk['permissions']), doc, apkel)
+
+            # TODO: remove old permission format
+            old_permissions = set()
+            for perm in apk['uses-permission']:
+                perm_name = perm.name
+                if perm_name.startswith("android.permission."):
+                    perm_name = perm_name[19:]
+                old_permissions.add(perm_name)
+            addElementNonEmpty('permissions', ','.join(old_permissions), doc, apkel)
+
+            for permission in apk['uses-permission']:
+                permel = doc.createElement('uses-permission')
+                permel.setAttribute('name', permission.name)
+                if permission.maxSdkVersion is not None:
+                    permel.setAttribute('maxSdkVersion', permission.maxSdkVersion)
+                    apkel.appendChild(permel)
+            for permission_sdk_23 in apk['uses-permission-sdk-23']:
+                permel = doc.createElement('uses-permission-sdk-23')
+                permel.setAttribute('name', permission_sdk_23.name)
+                if permission_sdk_23.maxSdkVersion is not None:
+                    permel.setAttribute('maxSdkVersion', permission_sdk_23.maxSdkVersion)
+                    apkel.appendChild(permel)
             if 'nativecode' in apk:
                 addElement('nativecode', ','.join(apk['nativecode']), doc, apkel)
             addElementNonEmpty('features', ','.join(apk['features']), doc, apkel)
@@ -937,9 +1113,9 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
                     os.symlink(sigfile_path, siglinkname)
 
     if options.pretty:
-        output = doc.toprettyxml()
+        output = doc.toprettyxml(encoding='utf-8')
     else:
-        output = doc.toxml()
+        output = doc.toxml(encoding='utf-8')
 
     with open(os.path.join(repodir, 'index.xml'), 'wb') as f:
         f.write(output)
@@ -966,7 +1142,7 @@ 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', 'SHA1withRSA',
                     signed, config['repo_keyalias']]
@@ -988,13 +1164,13 @@ def make_index(apps, sortedids, apks, repodir, archive, categories):
     catdata = ''
     for cat in categories:
         catdata += cat + '\n'
-    with open(os.path.join(repodir, 'categories.txt'), 'w') as f:
+    with open(os.path.join(repodir, 'categories.txt'), 'w', encoding='utf8') as f:
         f.write(catdata)
 
 
 def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversions):
 
-    for appid, app in apps.iteritems():
+    for appid, app in apps.items():
 
         if app.ArchivePolicy:
             keepversions = int(app.ArchivePolicy[:-9])
@@ -1017,6 +1193,9 @@ def archive_old_apks(apps, apks, archapks, repodir, archivedir, defaultkeepversi
             to_path = os.path.join(to_dir, filename)
             shutil.move(from_path, to_path)
 
+        logging.debug("Checking archiving for {0} - apks:{1}, keepversions:{2}, archapks:{3}"
+                      .format(appid, len(apks), keepversions, len(archapks)))
+
         if len(apks) > keepversions:
             apklist = filter_apk_list_sorted(apks)
             # Move back the ones we don't want.
@@ -1092,7 +1271,7 @@ def main():
     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")
+                        help="Delete APKs and/or OBBs 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",
@@ -1110,10 +1289,16 @@ def main():
                         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')
@@ -1162,7 +1347,7 @@ def main():
 
     # Generate a list of categories...
     categories = set()
-    for app in apps.itervalues():
+    for app in apps.values():
         categories.update(app.Categories)
 
     # Read known apks data (will be updated and written back when we've finished)
@@ -1173,14 +1358,16 @@ def main():
     apkcachefile = os.path.join('tmp', 'apkcache')
     if not options.clean and os.path.exists(apkcachefile):
         with open(apkcachefile, 'rb') as cf:
-            apkcache = pickle.load(cf)
+            apkcache = pickle.load(cf, encoding='utf-8')
+        if apkcache.get("METADATA_VERSION") != METADATA_VERSION:
+            apkcache = {}
     else:
         apkcache = {}
 
     delete_disabled_builds(apps, apkcache, repodirs)
 
     # Scan all apks in the main repo
-    apks, cachechanged = scan_apks(apps, apkcache, repodirs[0], knownapks)
+    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)
@@ -1191,7 +1378,7 @@ def main():
                 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 = open(os.path.join('metadata', apk['id'] + '.txt'), 'w', encoding='utf8')
                 f.write("License:Unknown\n")
                 f.write("Web Site:\n")
                 f.write("Source Code:\n")
@@ -1220,9 +1407,11 @@ def main():
     if newmetadata:
         apps = metadata.read_metadata()
 
+    insert_obbs(repodirs[0], apps, apks)
+
     # 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:
@@ -1232,7 +1421,7 @@ 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 appid, app in apps.iteritems():
+    for appid, app in apps.items():
         bestver = 0
         for apk in apks + archapks:
             if apk['id'] == appid:
@@ -1260,17 +1449,19 @@ def main():
             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.keys(), 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():
+        for appid, app in apps.items():
             repodir = os.path.join(appid, 'fdroid', 'repo')
             appdict = dict()
             appdict[appid] = app
@@ -1299,18 +1490,20 @@ def main():
         # Generate latest apps data for widget
         if os.path.exists(os.path.join('stats', 'latestapps.txt')):
             data = ''
-            for line in file(os.path.join('stats', 'latestapps.txt')):
-                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"
-            with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w') as f:
+            with open(os.path.join('stats', 'latestapps.txt'), 'r', encoding='utf8') as f:
+                for line in f:
+                    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"
+            with open(os.path.join(repodirs[0], 'latestapps.dat'), 'w', encoding='utf8') as f:
                 f.write(data)
 
     if cachechanged:
+        apkcache["METADATA_VERSION"] = METADATA_VERSION
         with open(apkcachefile, 'wb') as cf:
             pickle.dump(apkcache, cf)