chiark / gitweb /
Rewrite scanner logic
[fdroidserver.git] / fdroidserver / common.py
index b9e7b81c615926c69f73fc45ae2bfe30228ac3e6..d93bf73927d223e710a994bf5ef3043238de9600 100644 (file)
@@ -17,6 +17,9 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+# common.py is imported by all modules, so do not import third-party
+# libraries here as they will become a requirement for all commands.
+
 import os
 import sys
 import re
@@ -28,25 +31,37 @@ import time
 import operator
 import Queue
 import threading
-import magic
 import logging
+import hashlib
+import socket
+import xml.etree.ElementTree as XMLElementTree
+
 from distutils.version import LooseVersion
+from zipfile import ZipFile
 
 import metadata
 
+XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
+
 config = None
 options = None
 env = None
+orig_path = None
 
 
 default_config = {
     'sdk_path': "$ANDROID_HOME",
-    'ndk_path': "$ANDROID_NDK",
-    'build_tools': "21.1.2",
+    'ndk_paths': {
+        'r9b': None,
+        'r10e': "$ANDROID_NDK",
+    },
+    'build_tools': "23.0.1",
     'ant': "ant",
     'mvn3': "mvn",
     'gradle': 'gradle',
+    'accepted_formats': ['txt', 'yaml'],
     'sync_from_local_copy_dir': False,
+    'per_app_repos': False,
     'make_current_version_link': True,
     'current_version_name_source': 'Name',
     'update_stats': False,
@@ -56,11 +71,11 @@ default_config = {
     'stats_to_carbon': False,
     'repo_maxage': 0,
     'build_server_always': False,
-    'keystore': os.path.join("$HOME", '.local', 'share', 'fdroidserver', 'keystore.jks'),
+    'keystore': 'keystore.jks',
     'smartcardoptions': [],
     'char_limits': {
-        'Summary': 50,
-        'Description': 1500
+        'Summary': 80,
+        'Description': 4000,
     },
     'keyaliases': {},
     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
@@ -76,20 +91,52 @@ default_config = {
 }
 
 
+def setup_global_opts(parser):
+    parser.add_argument("-v", "--verbose", action="store_true", default=False,
+                        help="Spew out even more information than normal")
+    parser.add_argument("-q", "--quiet", action="store_true", default=False,
+                        help="Restrict output to warnings and errors")
+
+
 def fill_config_defaults(thisconfig):
     for k, v in default_config.items():
         if k not in thisconfig:
             thisconfig[k] = v
 
     # Expand paths (~users and $vars)
-    for k in ['sdk_path', 'ndk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
+    def expand_path(path):
+        if path is None:
+            return None
+        orig = path
+        path = os.path.expanduser(path)
+        path = os.path.expandvars(path)
+        if orig == path:
+            return None
+        return path
+
+    for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
         v = thisconfig[k]
-        orig = v
-        v = os.path.expanduser(v)
-        v = os.path.expandvars(v)
-        if orig != v:
-            thisconfig[k] = v
-            thisconfig[k + '_orig'] = orig
+        exp = expand_path(v)
+        if exp is not None:
+            thisconfig[k] = exp
+            thisconfig[k + '_orig'] = v
+
+    for k in ['ndk_paths']:
+        d = thisconfig[k]
+        for k2 in d.copy():
+            v = d[k2]
+            exp = expand_path(v)
+            if exp is not None:
+                thisconfig[k][k2] = exp
+                thisconfig[k][k2 + '_orig'] = v
+
+
+def regsub_file(pattern, repl, path):
+    with open(path, 'r') as f:
+        text = f.read()
+    text = re.sub(pattern, repl, text)
+    with open(path, 'w') as f:
+        f.write(text)
 
 
 def read_config(opts, config_file='config.py'):
@@ -98,7 +145,7 @@ def read_config(opts, config_file='config.py'):
     The config is read from config_file, which is in the current directory when
     any of the repo management commands are used.
     """
-    global config, options, env
+    global config, options, env, orig_path
 
     if config is not None:
         return config
@@ -133,10 +180,9 @@ def read_config(opts, config_file='config.py'):
     # There is no standard, so just set up the most common environment
     # variables
     env = os.environ
+    orig_path = env['PATH']
     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
         env[n] = config['sdk_path']
-    for n in ['ANDROID_NDK', 'NDK']:
-        env[n] = config['ndk_path']
 
     for k in ["keystorepass", "keypass"]:
         if k in config:
@@ -165,6 +211,15 @@ def read_config(opts, config_file='config.py'):
     return config
 
 
+def get_ndk_path(version):
+    if version is None:
+        version = 'r10e'  # falls back to latest
+    paths = config['ndk_paths']
+    if version not in paths:
+        return ''
+    return paths[version] or ''
+
+
 def find_sdk_tools_cmd(cmd):
     '''find a working path to a tool from the Android SDK'''
 
@@ -312,10 +367,16 @@ def read_app_args(args, allapps, allow_vercodes=False):
     return apps
 
 
-def has_extension(filename, extension):
-    name, ext = os.path.splitext(filename)
-    ext = ext.lower()[1:]
-    return ext == extension
+def get_extension(filename):
+    _, ext = os.path.splitext(filename)
+    if not ext:
+        return ''
+    return ext.lower()[1:]
+
+
+def has_extension(filename, ext):
+    return ext == get_extension(filename)
+
 
 apk_regex = None
 
@@ -398,6 +459,8 @@ class vcs:
         self.username = None
         if self.repotype() in ('git-svn', 'bzr'):
             if '@' in remote:
+                if self.repotype == 'git-svn':
+                    raise VCSException("Authentication is not supported for git-svn")
                 self.username, remote = remote.split('@')
                 if ':' not in self.username:
                     raise VCSException("Password required with username")
@@ -419,7 +482,7 @@ class vcs:
     # lifetime of the vcs object.
     # None is acceptable for 'rev' if you know you are cloning a clean copy of
     # the repo - otherwise it must specify a valid revision.
-    def gotorevision(self, rev):
+    def gotorevision(self, rev, refresh=True):
 
         if self.clone_failed:
             raise VCSException("Downloading the repository already failed once, not trying again.")
@@ -450,6 +513,8 @@ class vcs:
             shutil.rmtree(self.local)
 
         exc = None
+        if not refresh:
+            self.refreshed = True
 
         try:
             self.gotorevisionx(rev)
@@ -475,10 +540,22 @@ class vcs:
 
     # Get a list of all known tags
     def gettags(self):
-        raise VCSException('gettags not supported for this vcs type')
-
-    # Get a list of latest number tags
-    def latesttags(self, number):
+        if not self._gettags:
+            raise VCSException('gettags not supported for this vcs type')
+        rtags = []
+        for tag in self._gettags():
+            if re.match('[-A-Za-z0-9_. /]+$', tag):
+                rtags.append(tag)
+        return rtags
+
+    def latesttags(self, tags, number):
+        """Get the most recent tags in a given list.
+
+        :param tags: a list of tags
+        :param number: the number to return
+        :returns: A list containing the most recent tags in the provided
+                  list, up to the maximum number given.
+        """
         raise VCSException('latesttags not supported for this vcs type')
 
     # Get current commit reference (hash, revision, etc)
@@ -517,12 +594,14 @@ class vcs_git(vcs):
         else:
             self.checkrepo()
             # Discard any working tree changes
-            p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
+            p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
+                             'git', 'reset', '--hard'], cwd=self.local, output=False)
             if p.returncode != 0:
                 raise VCSException("Git reset failed", p.output)
             # Remove untracked files now, in case they're tracked in the target
             # revision (it happens!)
-            p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
+            p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
+                             'git', 'clean', '-dffx'], cwd=self.local, output=False)
             if p.returncode != 0:
                 raise VCSException("Git clean failed", p.output)
             if not self.refreshed:
@@ -570,13 +649,6 @@ class vcs_git(vcs):
                     line = line.replace('git@github.com:', 'https://github.com/')
                 f.write(line)
 
-        for cmd in [
-                ['git', 'reset', '--hard'],
-                ['git', 'clean', '-dffx'],
-                ]:
-            p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
-            if p.returncode != 0:
-                raise VCSException("Git submodule reset failed", p.output)
         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
         if p.returncode != 0:
             raise VCSException("Git submodule sync failed", p.output)
@@ -584,19 +656,26 @@ class vcs_git(vcs):
         if p.returncode != 0:
             raise VCSException("Git submodule update failed", p.output)
 
-    def gettags(self):
+    def _gettags(self):
         self.checkrepo()
         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
         return p.output.splitlines()
 
-    def latesttags(self, alltags, number):
+    def latesttags(self, tags, number):
         self.checkrepo()
-        p = FDroidPopen(['echo "' + '\n'.join(alltags) + '" | '
-                         +
-                         'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
-                         + 'sort -n | awk \'{print $2}\''],
-                        cwd=self.local, shell=True, output=False)
-        return p.output.splitlines()[-number:]
+        tl = []
+        for tag in tags:
+            p = FDroidPopen(
+                ['git', 'show', '--format=format:%ct', '-s', tag],
+                cwd=self.local, output=False)
+            # Timestamp is on the last line. For a normal tag, it's the only
+            # line, but for annotated tags, the rest of the info precedes it.
+            ts = int(p.output.splitlines()[-1])
+            tl.append((ts, tag))
+        latest = []
+        for _, t in sorted(tl)[-number:]:
+            latest.append(t)
+        return latest
 
 
 class vcs_gitsvn(vcs):
@@ -604,13 +683,6 @@ class vcs_gitsvn(vcs):
     def repotype(self):
         return 'git-svn'
 
-    # Damn git-svn tries to use a graphical password prompt, so we have to
-    # trick it into taking the password from stdin
-    def userargs(self):
-        if self.username is None:
-            return ('', '')
-        return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
-
     # If the local directory exists, but is somehow not a git repository, git
     # will traverse up the directory tree until it finds one that is (i.e.
     # fdroidserver) and then we'll proceed to destory it! This is called as
@@ -624,22 +696,24 @@ class vcs_gitsvn(vcs):
     def gotorevisionx(self, rev):
         if not os.path.exists(self.local):
             # Brand new checkout
-            gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
+            gitsvn_args = ['git', 'svn', 'clone']
             if ';' in self.remote:
                 remote_split = self.remote.split(';')
                 for i in remote_split[1:]:
                     if i.startswith('trunk='):
-                        gitsvn_cmd += ' -T %s' % i[6:]
+                        gitsvn_args.extend(['-T', i[6:]])
                     elif i.startswith('tags='):
-                        gitsvn_cmd += ' -t %s' % i[5:]
+                        gitsvn_args.extend(['-t', i[5:]])
                     elif i.startswith('branches='):
-                        gitsvn_cmd += ' -b %s' % i[9:]
-                p = FDroidPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True, output=False)
+                        gitsvn_args.extend(['-b', i[9:]])
+                gitsvn_args.extend([remote_split[0], self.local])
+                p = FDroidPopen(gitsvn_args, output=False)
                 if p.returncode != 0:
                     self.clone_failed = True
                     raise VCSException("Git svn clone failed", p.output)
             else:
-                p = FDroidPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True, output=False)
+                gitsvn_args.extend([self.remote, self.local])
+                p = FDroidPopen(gitsvn_args, output=False)
                 if p.returncode != 0:
                     self.clone_failed = True
                     raise VCSException("Git svn clone failed", p.output)
@@ -657,10 +731,10 @@ class vcs_gitsvn(vcs):
                 raise VCSException("Git clean failed", p.output)
             if not self.refreshed:
                 # Get new commits, branches and tags from repo
-                p = FDroidPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True, output=False)
+                p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
                 if p.returncode != 0:
                     raise VCSException("Git svn fetch failed")
-                p = FDroidPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True, output=False)
+                p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
                 if p.returncode != 0:
                     raise VCSException("Git svn rebase failed", p.output)
                 self.refreshed = True
@@ -713,7 +787,7 @@ class vcs_gitsvn(vcs):
         if p.returncode != 0:
             raise VCSException("Git clean failed", p.output)
 
-    def gettags(self):
+    def _gettags(self):
         self.checkrepo()
         for treeish in ['origin/', '']:
             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
@@ -740,9 +814,13 @@ class vcs_hg(vcs):
                 self.clone_failed = True
                 raise VCSException("Hg clone failed", p.output)
         else:
-            p = FDroidPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True, output=False)
+            p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
             if p.returncode != 0:
-                raise VCSException("Hg clean failed", p.output)
+                raise VCSException("Hg status failed", p.output)
+            for line in p.output.splitlines():
+                if not line.startswith('? '):
+                    raise VCSException("Unexpected output from hg status -uS: " + line)
+                FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
             if not self.refreshed:
                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
                 if p.returncode != 0:
@@ -766,7 +844,7 @@ class vcs_hg(vcs):
         elif p.returncode != 0:
             raise VCSException("HG purge failed", p.output)
 
-    def gettags(self):
+    def _gettags(self):
         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
         return p.output.splitlines()[1:]
 
@@ -797,41 +875,49 @@ class vcs_bzr(vcs):
         if p.returncode != 0:
             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
 
-    def gettags(self):
+    def _gettags(self):
         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
         return [tag.split('   ')[0].strip() for tag in
                 p.output.splitlines()]
 
 
-def retrieve_string(app_dir, string, xmlfiles=None):
+def unescape_string(string):
+    if string[0] == '"' and string[-1] == '"':
+        return string[1:-1]
+
+    return string.replace("\\'", "'")
 
-    res_dirs = [
-        os.path.join(app_dir, 'res'),
-        os.path.join(app_dir, 'src', 'main'),
-        ]
+
+def retrieve_string(app_dir, string, xmlfiles=None):
 
     if xmlfiles is None:
         xmlfiles = []
-        for res_dir in res_dirs:
+        for res_dir in [
+            os.path.join(app_dir, 'res'),
+            os.path.join(app_dir, 'src', 'main', 'res'),
+        ]:
             for r, d, f in os.walk(res_dir):
                 if os.path.basename(r) == 'values':
                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
 
-    string_search = None
-    if string.startswith('@string/'):
-        string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
-    elif string.startswith('&') and string.endswith(';'):
-        string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
-
-    if string_search is not None:
-        for xmlfile in xmlfiles:
-            for line in file(xmlfile):
-                matches = string_search(line)
-                if matches:
-                    return retrieve_string(app_dir, matches.group(1), xmlfiles)
-        return None
+    if not string.startswith('@string/'):
+        return unescape_string(string)
 
-    return string.replace("\\'", "'")
+    name = string[len('@string/'):]
+
+    for path in xmlfiles:
+        if not os.path.isfile(path):
+            continue
+        xml = parse_xml(path)
+        element = xml.find('string[@name="' + name + '"]')
+        if element is not None and element.text is not None:
+            return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
+
+    return ''
+
+
+def retrieve_string_singleline(app_dir, string, xmlfiles=None):
+    return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
 
 
 # Return list of existing files that will be used to find the highest vercode
@@ -854,38 +940,20 @@ def manifest_paths(app_dir, flavours):
 
 # Retrieve the package name. Returns the name, or None if not found.
 def fetch_real_name(app_dir, flavours):
-    app_search = re.compile(r'.*<application.*').search
-    name_search = re.compile(r'.*android:label="([^"]+)".*').search
-    app_found = False
-    for f in manifest_paths(app_dir, flavours):
-        if not has_extension(f, 'xml'):
+    for path in manifest_paths(app_dir, flavours):
+        if not has_extension(path, 'xml') or not os.path.isfile(path):
             continue
-        logging.debug("fetch_real_name: Checking manifest at " + f)
-        for line in file(f):
-            if not app_found:
-                if app_search(line):
-                    app_found = True
-            if app_found:
-                matches = name_search(line)
-                if matches:
-                    stringname = matches.group(1)
-                    logging.debug("fetch_real_name: using string " + stringname)
-                    result = retrieve_string(app_dir, stringname)
-                    if result:
-                        result = result.strip()
-                    return result
-    return None
-
-
-# Retrieve the version name
-def version_name(original, app_dir, flavours):
-    for f in manifest_paths(app_dir, flavours):
-        if not has_extension(f, 'xml'):
+        logging.debug("fetch_real_name: Checking manifest at " + path)
+        xml = parse_xml(path)
+        app = xml.find('application')
+        if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
             continue
-        string = retrieve_string(app_dir, original)
-        if string:
-            return string
-    return original
+        label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
+        result = retrieve_string_singleline(app_dir, label)
+        if result:
+            result = result.strip()
+        return result
+    return None
 
 
 def get_library_references(root_dir):
@@ -893,16 +961,15 @@ def get_library_references(root_dir):
     proppath = os.path.join(root_dir, 'project.properties')
     if not os.path.isfile(proppath):
         return libraries
-    with open(proppath) as f:
-        for line in f.readlines():
-            if not line.startswith('android.library.reference.'):
-                continue
-            path = line.split('=')[1].strip()
-            relpath = os.path.join(root_dir, path)
-            if not os.path.isdir(relpath):
-                continue
-            logging.debug("Found subproject at %s" % path)
-            libraries.append(path)
+    for line in file(proppath):
+        if not line.startswith('android.library.reference.'):
+            continue
+        path = line.split('=')[1].strip()
+        relpath = os.path.join(root_dir, path)
+        if not os.path.isdir(relpath):
+            continue
+        logging.debug("Found subproject at %s" % path)
+        libraries.append(path)
     return libraries
 
 
@@ -922,10 +989,9 @@ def remove_debuggable_flags(root_dir):
     logging.debug("Removing debuggable flags from %s" % root_dir)
     for root, dirs, files in os.walk(root_dir):
         if 'AndroidManifest.xml' in files:
-            path = os.path.join(root, 'AndroidManifest.xml')
-            p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
-            if p.returncode != 0:
-                raise BuildException("Failed to remove debuggable flags of %s" % path)
+            regsub_file(r'android:debuggable="[^"]*"',
+                        '',
+                        os.path.join(root, 'AndroidManifest.xml'))
 
 
 # Extract some information from the AndroidManifest.xml at the given path.
@@ -936,13 +1002,9 @@ def parse_androidmanifests(paths, ignoreversions=None):
     if not paths:
         return (None, None, None)
 
-    vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
-    vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
-    psearch = re.compile(r'.*package="([^"]+)".*').search
-
     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
-    psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
+    psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
 
     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
 
@@ -952,34 +1014,50 @@ def parse_androidmanifests(paths, ignoreversions=None):
 
     for path in paths:
 
+        if not os.path.isfile(path):
+            continue
+
+        logging.debug("Parsing manifest at {0}".format(path))
         gradle = has_extension(path, 'gradle')
         version = None
         vercode = None
-        # Remember package name, may be defined separately from version+vercode
-        package = max_package
+        package = None
 
-        for line in file(path):
-            if not package:
-                if gradle:
+        if gradle:
+            for line in file(path):
+                # Grab first occurence of each to avoid running into
+                # alternative flavours and builds.
+                if not package:
                     matches = psearch_g(line)
-                else:
-                    matches = psearch(line)
-                if matches:
-                    package = matches.group(1)
-            if not version:
-                if gradle:
+                    if matches:
+                        package = matches.group(2)
+                if not version:
                     matches = vnsearch_g(line)
-                else:
-                    matches = vnsearch(line)
-                if matches:
-                    version = matches.group(2 if gradle else 1)
-            if not vercode:
-                if gradle:
+                    if matches:
+                        version = matches.group(2)
+                if not vercode:
                     matches = vcsearch_g(line)
-                else:
-                    matches = vcsearch(line)
-                if matches:
-                    vercode = matches.group(1)
+                    if matches:
+                        vercode = matches.group(1)
+        else:
+            xml = parse_xml(path)
+            if "package" in xml.attrib:
+                package = xml.attrib["package"].encode('utf-8')
+            if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
+                version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
+                base_dir = os.path.dirname(path)
+                version = retrieve_string_singleline(base_dir, version)
+            if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
+                a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
+                if string_is_integer(a):
+                    vercode = a
+
+        # Remember package name, may be defined separately from version+vercode
+        if package is None:
+            package = max_package
+
+        logging.debug("..got package={0}, version={1}, vercode={2}"
+                      .format(package, version, vercode))
 
         # Always grab the package name and version name in case they are not
         # together with the highest version code
@@ -1002,9 +1080,16 @@ def parse_androidmanifests(paths, ignoreversions=None):
     if max_version is None:
         max_version = "Unknown"
 
+    if max_package and not is_valid_package_name(max_package):
+        raise FDroidException("Invalid package name {0}".format(max_package))
+
     return (max_version, max_vercode, max_package)
 
 
+def is_valid_package_name(name):
+    return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
+
+
 class FDroidException(Exception):
 
     def __init__(self, value, detail=None):
@@ -1040,8 +1125,8 @@ class BuildException(FDroidException):
 # Returns the path to it. Normally this is the path to be used when referencing
 # it, which may be a subdirectory of the actual project. If you want the base
 # directory of the project, pass 'basepath=True'.
-def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
-              basepath=False, raw=False, prepare=True, preponly=False):
+def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
+              raw=False, prepare=True, preponly=False, refresh=True):
 
     number = None
     subdir = None
@@ -1066,7 +1151,7 @@ def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
         vcs.srclib = (name, number, sdir)
         if ref:
-            vcs.gotorevision(ref)
+            vcs.gotorevision(ref, refresh)
 
         if raw:
             return vcs
@@ -1084,27 +1169,13 @@ def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
     if libdir is None:
         libdir = sdir
 
-    if srclib["Srclibs"]:
-        n = 1
-        for lib in srclib["Srclibs"].replace(';', ',').split(','):
-            s_tuple = None
-            for t in srclibpaths:
-                if t[0] == lib:
-                    s_tuple = t
-                    break
-            if s_tuple is None:
-                raise VCSException('Missing recursive srclib %s for %s' % (
-                    lib, name))
-            place_srclib(libdir, n, s_tuple[2])
-            n += 1
-
     remove_signing_keys(sdir)
     remove_debuggable_flags(sdir)
 
     if prepare:
 
         if srclib["Prepare"]:
-            cmd = replace_config_vars(srclib["Prepare"])
+            cmd = replace_config_vars(srclib["Prepare"], None)
 
             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
             if p.returncode != 0:
@@ -1131,7 +1202,7 @@ def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
 #   'root' is the root directory, which may be the same as 'build_dir' or may
 #          be a subdirectory of it.
 #   'srclibpaths' is information on the srclibs being used
-def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
+def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
 
     # Optionally, the actual app source can be in a subdirectory
     if build['subdir']:
@@ -1141,9 +1212,9 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
 
     # Get a working copy of the right revision
     logging.info("Getting source for revision " + build['commit'])
-    vcs.gotorevision(build['commit'])
+    vcs.gotorevision(build['commit'], refresh)
 
-    # Initialise submodules if requred
+    # Initialise submodules if required
     if build['submodules']:
         logging.info("Initialising submodules")
         vcs.initsubmodules()
@@ -1155,7 +1226,7 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
 
     # Run an init command if one is required
     if build['init']:
-        cmd = replace_config_vars(build['init'])
+        cmd = replace_config_vars(build['init'], build)
         logging.info("Running 'init' commands in %s" % root_dir)
 
         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
@@ -1179,8 +1250,7 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
     if build['srclibs']:
         logging.info("Collecting source libraries")
         for lib in build['srclibs']:
-            srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
-                                         preponly=onserver))
+            srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
 
     for name, number, libpath in srclibpaths:
         place_srclib(root_dir, int(number) if number else None, libpath)
@@ -1198,9 +1268,8 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
         props = ""
         if os.path.isfile(path):
             logging.info("Updating local.properties file at %s" % path)
-            f = open(path, 'r')
-            props += f.read()
-            f.close()
+            with open(path, 'r') as f:
+                props += f.read()
             props += '\n'
         else:
             logging.info("Creating local.properties file at %s" % path)
@@ -1213,38 +1282,42 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
         else:
             props += "sdk.dir=%s\n" % config['sdk_path']
             props += "sdk-location=%s\n" % config['sdk_path']
-        if config['ndk_path']:
+        if build['ndk_path']:
             # Add ndk location
-            props += "ndk.dir=%s\n" % config['ndk_path']
-            props += "ndk-location=%s\n" % config['ndk_path']
+            props += "ndk.dir=%s\n" % build['ndk_path']
+            props += "ndk-location=%s\n" % build['ndk_path']
         # Add java.encoding if necessary
         if build['encoding']:
             props += "java.encoding=%s\n" % build['encoding']
-        f = open(path, 'w')
-        f.write(props)
-        f.close()
+        with open(path, 'w') as f:
+            f.write(props)
 
     flavours = []
     if build['type'] == 'gradle':
         flavours = build['gradle']
 
-        version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
+        version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
         gradlepluginver = None
 
-        gradle_files = [os.path.join(root_dir, 'build.gradle')]
+        gradle_dirs = [root_dir]
 
         # Parent dir build.gradle
         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
         if parent_dir.startswith(build_dir):
-            gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
+            gradle_dirs.append(parent_dir)
 
-        for path in gradle_files:
+        for dir_path in gradle_dirs:
             if gradlepluginver:
                 break
-            if not os.path.isfile(path):
+            if not os.path.isdir(dir_path):
                 continue
-            with open(path) as f:
-                for line in f:
+            for filename in os.listdir(dir_path):
+                if not filename.endswith('.gradle'):
+                    continue
+                path = os.path.join(dir_path, filename)
+                if not os.path.isfile(path):
+                    continue
+                for line in file(path):
                     match = version_regex.match(line)
                     if match:
                         gradlepluginver = match.group(1)
@@ -1258,9 +1331,9 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
 
         if build['target']:
             n = build["target"].split('-')[1]
-            FDroidPopen(['sed', '-i',
-                         's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
-                         'build.gradle'], cwd=root_dir, output=False)
+            regsub_file(r'compileSdkVersion[ =]+[0-9]+',
+                        r'compileSdkVersion %s' % n,
+                        os.path.join(root_dir, 'build.gradle'))
 
     # Remove forced debuggable flags
     remove_debuggable_flags(root_dir)
@@ -1272,34 +1345,27 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
             if not os.path.isfile(path):
                 continue
             if has_extension(path, 'xml'):
-                p = FDroidPopen(['sed', '-i',
-                                 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
-                                 path], output=False)
-                if p.returncode != 0:
-                    raise BuildException("Failed to amend manifest")
+                regsub_file(r'android:versionName="[^"]*"',
+                            r'android:versionName="%s"' % build['version'],
+                            path)
             elif has_extension(path, 'gradle'):
-                p = FDroidPopen(['sed', '-i',
-                                 's/versionName *=* *"[^"]*"/versionName = "' + build['version'] + '"/g',
-                                 path], output=False)
-                if p.returncode != 0:
-                    raise BuildException("Failed to amend build.gradle")
+                regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
+                            r"""\1versionName '%s'""" % build['version'],
+                            path)
+
     if build['forcevercode']:
         logging.info("Changing the version code")
         for path in manifest_paths(root_dir, flavours):
             if not os.path.isfile(path):
                 continue
             if has_extension(path, 'xml'):
-                p = FDroidPopen(['sed', '-i',
-                                 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
-                                 path], output=False)
-                if p.returncode != 0:
-                    raise BuildException("Failed to amend manifest")
+                regsub_file(r'android:versionCode="[^"]*"',
+                            r'android:versionCode="%s"' % build['vercode'],
+                            path)
             elif has_extension(path, 'gradle'):
-                p = FDroidPopen(['sed', '-i',
-                                 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
-                                 path], output=False)
-                if p.returncode != 0:
-                    raise BuildException("Failed to amend build.gradle")
+                regsub_file(r'versionCode[ =]+[0-9]+',
+                            r'versionCode %s' % build['vercode'],
+                            path)
 
     # Delete unwanted files
     if build['rm']:
@@ -1309,9 +1375,9 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
             logging.info("Removing {0}".format(part))
             if os.path.lexists(dest):
                 if os.path.islink(dest):
-                    FDroidPopen(['unlink ' + dest], shell=True, output=False)
+                    FDroidPopen(['unlink', dest], output=False)
                 else:
-                    FDroidPopen(['rm -rf ' + dest], shell=True, output=False)
+                    FDroidPopen(['rm', '-rf', dest], output=False)
             else:
                 logging.info("...but it didn't exist")
 
@@ -1336,7 +1402,7 @@ def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=
     if build['prebuild']:
         logging.info("Running 'prebuild' commands in %s" % root_dir)
 
-        cmd = replace_config_vars(build['prebuild'])
+        cmd = replace_config_vars(build['prebuild'], build)
 
         # Substitute source library paths into prebuild commands
         for name, number, libpath in srclibpaths:
@@ -1394,141 +1460,8 @@ def getpaths(build_dir, build, field):
     return paths
 
 
-# Scan the source code in the given directory (and all subdirectories)
-# and return the number of fatal problems encountered
-def scan_source(build_dir, root_dir, thisbuild):
-
-    count = 0
-
-    # Common known non-free blobs (always lower case):
-    usual_suspects = [
-        re.compile(r'flurryagent', re.IGNORECASE),
-        re.compile(r'paypal.*mpl', re.IGNORECASE),
-        re.compile(r'google.*analytics', re.IGNORECASE),
-        re.compile(r'admob.*sdk.*android', re.IGNORECASE),
-        re.compile(r'google.*ad.*view', re.IGNORECASE),
-        re.compile(r'google.*admob', re.IGNORECASE),
-        re.compile(r'google.*play.*services', re.IGNORECASE),
-        re.compile(r'crittercism', re.IGNORECASE),
-        re.compile(r'heyzap', re.IGNORECASE),
-        re.compile(r'jpct.*ae', re.IGNORECASE),
-        re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
-        re.compile(r'bugsense', re.IGNORECASE),
-        re.compile(r'crashlytics', re.IGNORECASE),
-        re.compile(r'ouya.*sdk', re.IGNORECASE),
-        re.compile(r'libspen23', re.IGNORECASE),
-    ]
-
-    scanignore = getpaths(build_dir, thisbuild, 'scanignore')
-    scandelete = getpaths(build_dir, thisbuild, 'scandelete')
-
-    try:
-        ms = magic.open(magic.MIME_TYPE)
-        ms.load()
-    except AttributeError:
-        ms = None
-
-    def toignore(fd):
-        for i in scanignore:
-            if fd.startswith(i):
-                return True
-        return False
-
-    def todelete(fd):
-        for i in scandelete:
-            if fd.startswith(i):
-                return True
-        return False
-
-    def removeproblem(what, fd, fp):
-        logging.info('Removing %s at %s' % (what, fd))
-        os.remove(fp)
-
-    def warnproblem(what, fd):
-        logging.warn('Found %s at %s' % (what, fd))
-
-    def handleproblem(what, fd, fp):
-        if toignore(fd):
-            logging.info('Ignoring %s at %s' % (what, fd))
-        elif todelete(fd):
-            removeproblem(what, fd, fp)
-        else:
-            logging.error('Found %s at %s' % (what, fd))
-            return True
-        return False
-
-    # Iterate through all files in the source code
-    for r, d, f in os.walk(build_dir, topdown=True):
-
-        # It's topdown, so checking the basename is enough
-        for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
-            if ignoredir in d:
-                d.remove(ignoredir)
-
-        for curfile in f:
-
-            # Path (relative) to the file
-            fp = os.path.join(r, curfile)
-            fd = fp[len(build_dir) + 1:]
-
-            try:
-                mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
-            except UnicodeError:
-                warnproblem('malformed magic number', fd)
-
-            if mime == 'application/x-sharedlib':
-                count += handleproblem('shared library', fd, fp)
-
-            elif mime == 'application/x-archive':
-                count += handleproblem('static library', fd, fp)
-
-            elif mime == 'application/x-executable':
-                count += handleproblem('binary executable', fd, fp)
-
-            elif mime == 'application/x-java-applet':
-                count += handleproblem('Java compiled class', fd, fp)
-
-            elif mime in (
-                    'application/jar',
-                    'application/zip',
-                    'application/java-archive',
-                    'application/octet-stream',
-                    'binary',
-            ):
-
-                if has_extension(fp, 'apk'):
-                    removeproblem('APK file', fd, fp)
-
-                elif has_extension(fp, 'jar'):
-
-                    if any(suspect.match(curfile) for suspect in usual_suspects):
-                        count += handleproblem('usual supect', fd, fp)
-                    else:
-                        warnproblem('JAR file', fd)
-
-                elif has_extension(fp, 'zip'):
-                    warnproblem('ZIP file', fd)
-
-                else:
-                    warnproblem('unknown compressed or binary file', fd)
-
-            elif has_extension(fp, 'java'):
-                for line in file(fp):
-                    if 'DexClassLoader' in line:
-                        count += handleproblem('DexClassLoader', fd, fp)
-                        break
-    if ms is not None:
-        ms.close()
-
-    # Presence of a jni directory without buildjni=yes might
-    # indicate a problem (if it's not a problem, explicitly use
-    # buildjni=no to bypass this check)
-    if (os.path.exists(os.path.join(root_dir, 'jni')) and
-            not thisbuild['buildjni']):
-        logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
-        count += 1
-
-    return count
+def natural_key(s):
+    return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
 
 
 class KnownApks:
@@ -1536,7 +1469,7 @@ class KnownApks:
     def __init__(self):
         self.path = os.path.join('stats', 'known_apks.txt')
         self.apks = {}
-        if os.path.exists(self.path):
+        if os.path.isfile(self.path):
             for line in file(self.path):
                 t = line.rstrip().split(' ')
                 if len(t) == 2:
@@ -1546,20 +1479,23 @@ class KnownApks:
         self.changed = False
 
     def writeifchanged(self):
-        if self.changed:
-            if not os.path.exists('stats'):
-                os.mkdir('stats')
-            f = open(self.path, 'w')
-            lst = []
-            for apk, app in self.apks.iteritems():
-                appid, added = app
-                line = apk + ' ' + appid
-                if added:
-                    line += ' ' + time.strftime('%Y-%m-%d', added)
-                lst.append(line)
-            for line in sorted(lst):
+        if not self.changed:
+            return
+
+        if not os.path.exists('stats'):
+            os.mkdir('stats')
+
+        lst = []
+        for apk, app in self.apks.iteritems():
+            appid, added = app
+            line = apk + ' ' + appid
+            if added:
+                line += ' ' + time.strftime('%Y-%m-%d', added)
+            lst.append(line)
+
+        with open(self.path, 'w') as f:
+            for line in sorted(lst, key=natural_key):
                 f.write(line + '\n')
-            f.close()
 
     # Record an apk (if it's new, otherwise does nothing)
     # Returns the date it was added.
@@ -1641,15 +1577,15 @@ class PopenResult:
     output = ''
 
 
-def SdkToolsPopen(commands, cwd=None, shell=False, output=True):
+def SdkToolsPopen(commands, cwd=None, output=True):
     cmd = commands[0]
     if cmd not in config:
         config[cmd] = find_sdk_tools_cmd(commands[0])
     return FDroidPopen([config[cmd]] + commands[1:],
-                       cwd=cwd, shell=shell, output=output)
+                       cwd=cwd, output=output)
 
 
-def FDroidPopen(commands, cwd=None, shell=False, output=True):
+def FDroidPopen(commands, cwd=None, output=True):
     """
     Run a command and capture the possibly huge output.
 
@@ -1668,10 +1604,11 @@ def FDroidPopen(commands, cwd=None, shell=False, output=True):
     result = PopenResult()
     p = None
     try:
-        p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
+        p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
     except OSError, e:
-        raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
+        raise BuildException("OSError while trying to execute " +
+                             ' '.join(commands) + ': ' + str(e))
 
     stdout_queue = Queue.Queue()
     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
@@ -1713,8 +1650,15 @@ def remove_signing_keys(build_dir):
             changed = False
 
             opened = 0
+            i = 0
             with open(path, "w") as o:
-                for line in lines:
+                while i < len(lines):
+                    line = lines[i]
+                    i += 1
+                    while line.endswith('\\\n'):
+                        line = line.rstrip('\\\n') + lines[i]
+                        i += 1
+
                     if comment.match(line):
                         continue
 
@@ -1742,8 +1686,7 @@ def remove_signing_keys(build_dir):
                 'project.properties',
                 'build.properties',
                 'default.properties',
-                'ant.properties',
-        ]:
+                'ant.properties', ]:
             if propfile in files:
                 path = os.path.join(root, propfile)
 
@@ -1764,10 +1707,30 @@ def remove_signing_keys(build_dir):
                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
 
 
-def replace_config_vars(cmd):
+def reset_env_path():
+    global env, orig_path
+    env['PATH'] = orig_path
+
+
+def add_to_env_path(path):
+    global env
+    paths = env['PATH'].split(os.pathsep)
+    if path in paths:
+        return
+    paths.append(path)
+    env['PATH'] = os.pathsep.join(paths)
+
+
+def replace_config_vars(cmd, build):
+    global env
     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
-    cmd = cmd.replace('$$NDK$$', config['ndk_path'])
+    # env['ANDROID_NDK'] is set in build_local right before prepare_source
+    cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
+    if build is not None:
+        cmd = cmd.replace('$$COMMIT$$', build['commit'])
+        cmd = cmd.replace('$$VERSION$$', build['version'])
+        cmd = cmd.replace('$$VERCODE$$', build['vercode'])
     return cmd
 
 
@@ -1794,6 +1757,39 @@ def place_srclib(root_dir, number, libpath):
             o.write('android.library.reference.%d=%s\n' % (number, relpath))
 
 
+def verify_apks(signed_apk, unsigned_apk, tmp_dir):
+    """Verify that two apks are the same
+
+    One of the inputs is signed, the other is unsigned. The signature metadata
+    is transferred from the signed to the unsigned apk, and then jarsigner is
+    used to verify that the signature from the signed apk is also varlid for
+    the unsigned one.
+    :param signed_apk: Path to a signed apk file
+    :param unsigned_apk: Path to an unsigned apk file expected to match it
+    :param tmp_dir: Path to directory for temporary files
+    :returns: None if the verification is successful, otherwise a string
+              describing what went wrong.
+    """
+    sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
+    with ZipFile(signed_apk) as signed_apk_as_zip:
+        meta_inf_files = ['META-INF/MANIFEST.MF']
+        for f in signed_apk_as_zip.namelist():
+            if sigfile.match(f):
+                meta_inf_files.append(f)
+        if len(meta_inf_files) < 3:
+            return "Signature files missing from {0}".format(signed_apk)
+        signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
+    with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
+        for meta_inf_file in meta_inf_files:
+            unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
+
+    if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
+        logging.info("...NOT verified - {0}".format(signed_apk))
+        return compare_apks(signed_apk, unsigned_apk, tmp_dir)
+    logging.info("...successfully verified")
+    return None
+
+
 def compare_apks(apk1, apk2, tmp_dir):
     """Compare two apks
 
@@ -1802,27 +1798,163 @@ def compare_apks(apk1, apk2, tmp_dir):
     trying to do the comparison.
     """
 
-    thisdir = os.path.join(tmp_dir, 'this_apk')
-    thatdir = os.path.join(tmp_dir, 'that_apk')
-    for d in [thisdir, thatdir]:
+    badchars = re.compile('''[/ :;'"]''')
+    apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
+    apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
+    for d in [apk1dir, apk2dir]:
         if os.path.exists(d):
             shutil.rmtree(d)
         os.mkdir(d)
+        os.mkdir(os.path.join(d, 'jar-xf'))
 
     if subprocess.call(['jar', 'xf',
                         os.path.abspath(apk1)],
-                       cwd=thisdir) != 0:
+                       cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
         return("Failed to unpack " + apk1)
     if subprocess.call(['jar', 'xf',
                         os.path.abspath(apk2)],
-                       cwd=thatdir) != 0:
+                       cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
         return("Failed to unpack " + apk2)
 
-    p = FDroidPopen(['diff', '-r', 'this_apk', 'that_apk'], cwd=tmp_dir,
-                    output=False)
+    # try to find apktool in the path, if it hasn't been manually configed
+    if 'apktool' not in config:
+        tmp = find_command('apktool')
+        if tmp is not None:
+            config['apktool'] = tmp
+    if 'apktool' in config:
+        if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
+                           cwd=apk1dir) != 0:
+            return("Failed to unpack " + apk1)
+        if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
+                           cwd=apk2dir) != 0:
+            return("Failed to unpack " + apk2)
+
+    p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
     lines = p.output.splitlines()
     if len(lines) != 1 or 'META-INF' not in lines[0]:
+        meld = find_command('meld')
+        if meld is not None:
+            p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
         return("Unexpected diff output - " + p.output)
 
+    # since everything verifies, delete the comparison to keep cruft down
+    shutil.rmtree(apk1dir)
+    shutil.rmtree(apk2dir)
+
     # If we get here, it seems like they're the same!
     return None
+
+
+def find_command(command):
+    '''find the full path of a command, or None if it can't be found in the PATH'''
+
+    def is_exe(fpath):
+        return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
+
+    fpath, fname = os.path.split(command)
+    if fpath:
+        if is_exe(command):
+            return command
+    else:
+        for path in os.environ["PATH"].split(os.pathsep):
+            path = path.strip('"')
+            exe_file = os.path.join(path, command)
+            if is_exe(exe_file):
+                return exe_file
+
+    return None
+
+
+def genpassword():
+    '''generate a random password for when generating keys'''
+    h = hashlib.sha256()
+    h.update(os.urandom(16))  # salt
+    h.update(bytes(socket.getfqdn()))
+    return h.digest().encode('base64').strip()
+
+
+def genkeystore(localconfig):
+    '''Generate a new key with random passwords and add it to new keystore'''
+    logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
+    keystoredir = os.path.dirname(localconfig['keystore'])
+    if keystoredir is None or keystoredir == '':
+        keystoredir = os.path.join(os.getcwd(), keystoredir)
+    if not os.path.exists(keystoredir):
+        os.makedirs(keystoredir, mode=0o700)
+
+    write_password_file("keystorepass", localconfig['keystorepass'])
+    write_password_file("keypass", localconfig['keypass'])
+    p = FDroidPopen(['keytool', '-genkey',
+                     '-keystore', localconfig['keystore'],
+                     '-alias', localconfig['repo_keyalias'],
+                     '-keyalg', 'RSA', '-keysize', '4096',
+                     '-sigalg', 'SHA256withRSA',
+                     '-validity', '10000',
+                     '-storepass:file', config['keystorepassfile'],
+                     '-keypass:file', config['keypassfile'],
+                     '-dname', localconfig['keydname']])
+    # TODO keypass should be sent via stdin
+    if p.returncode != 0:
+        raise BuildException("Failed to generate key", p.output)
+    os.chmod(localconfig['keystore'], 0o0600)
+    # now show the lovely key that was just generated
+    p = FDroidPopen(['keytool', '-list', '-v',
+                     '-keystore', localconfig['keystore'],
+                     '-alias', localconfig['repo_keyalias'],
+                     '-storepass:file', config['keystorepassfile']])
+    logging.info(p.output.strip() + '\n\n')
+
+
+def write_to_config(thisconfig, key, value=None):
+    '''write a key/value to the local config.py'''
+    if value is None:
+        origkey = key + '_orig'
+        value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
+    with open('config.py', 'r') as f:
+        data = f.read()
+    pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
+    repl = '\n' + key + ' = "' + value + '"'
+    data = re.sub(pattern, repl, data)
+    # if this key is not in the file, append it
+    if not re.match('\s*' + key + '\s*=\s*"', data):
+        data += repl
+    # make sure the file ends with a carraige return
+    if not re.match('\n$', data):
+        data += '\n'
+    with open('config.py', 'w') as f:
+        f.writelines(data)
+
+
+def parse_xml(path):
+    return XMLElementTree.parse(path).getroot()
+
+
+def string_is_integer(string):
+    try:
+        int(string)
+        return True
+    except ValueError:
+        return False
+
+
+def get_per_app_repos():
+    '''per-app repos are dirs named with the packageName of a single app'''
+
+    # Android packageNames are Java packages, they may contain uppercase or
+    # lowercase letters ('A' through 'Z'), numbers, and underscores
+    # ('_'). However, individual package name parts may only start with
+    # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
+    p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
+
+    repos = []
+    for root, dirs, files in os.walk(os.getcwd()):
+        for d in dirs:
+            print 'checking', root, 'for', d
+            if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
+                # standard parts of an fdroid repo, so never packageNames
+                continue
+            elif p.match(d) \
+                    and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
+                repos.append(d)
+        break
+    return repos