# 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
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
+from fdroidserver.asynchronousfilereader import AsynchronousFileReader
+
+
+XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
config = None
options = None
'sdk_path': "$ANDROID_HOME",
'ndk_paths': {
'r9b': None,
- 'r10d': "$ANDROID_NDK"
+ 'r10e': "$ANDROID_NDK",
},
- 'build_tools': "21.1.2",
+ '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,
'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",
}
+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][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'):
"""Read the repository config
def get_ndk_path(version):
if version is None:
- version = 'r10d' # latest
+ version = 'r10e' # falls back to latest
paths = config['ndk_paths']
if version not in paths:
return ''
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
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")
# 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.")
shutil.rmtree(self.local)
exc = None
+ if not refresh:
+ self.refreshed = True
try:
self.gotorevisionx(rev)
raise VCSException('gettags not supported for this vcs type')
rtags = []
for tag in self._gettags():
- if re.match('[-A-Za-z0-9_. ]+$', tag):
+ if re.match('[-A-Za-z0-9_. /]+$', tag):
rtags.append(tag)
return rtags
- # Get a list of latest number tags
- def latesttags(self, number):
+ 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)
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:
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)
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):
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
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)
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
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:
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
# 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):
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
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.
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
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))
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):
# 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
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
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:
# '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']:
# 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()
# 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)
if build['srclibs']:
logging.info("Collecting source libraries")
for lib in build['srclibs']:
- srclibpaths.append(getsrclib(lib, srclib_dir, build, 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)
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)
# 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)
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)
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']:
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")
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:
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')
-
- scanignore_worked = set()
- scandelete_worked = set()
-
- try:
- ms = magic.open(magic.MIME_TYPE)
- ms.load()
- except AttributeError:
- ms = None
-
- def toignore(fd):
- for p in scanignore:
- if fd.startswith(p):
- scanignore_worked.add(p)
- return True
- return False
-
- def todelete(fd):
- for p in scandelete:
- if fd.startswith(p):
- scandelete_worked.add(p)
- return True
- return False
-
- def ignoreproblem(what, fd, fp):
- logging.info('Ignoring %s at %s' % (what, fd))
- return 0
-
- def removeproblem(what, fd, fp):
- logging.info('Removing %s at %s' % (what, fd))
- os.remove(fp)
- return 0
-
- def warnproblem(what, fd):
- logging.warn('Found %s at %s' % (what, fd))
-
- def handleproblem(what, fd, fp):
- if toignore(fd):
- return ignoreproblem(what, fd, fp)
- if todelete(fd):
- return removeproblem(what, fd, fp)
- logging.error('Found %s at %s' % (what, fd))
- return 1
-
- # 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()
-
- for p in scanignore:
- if p not in scanignore_worked:
- logging.error('Unused scanignore path: %s' % p)
- count += 1
-
- for p in scandelete:
- if p not in scandelete_worked:
- logging.error('Unused scandelete path: %s' % p)
- count += 1
-
- # 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:
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:
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.
return False
-class AsynchronousFileReader(threading.Thread):
-
- '''
- Helper class to implement asynchronous reading of a file
- in a separate thread. Pushes read lines on a queue to
- be consumed in another thread.
- '''
-
- def __init__(self, fd, queue):
- assert isinstance(queue, Queue.Queue)
- assert callable(fd.readline)
- threading.Thread.__init__(self)
- self._fd = fd
- self._queue = queue
-
- def run(self):
- '''The body of the tread: read lines and put them on the queue.'''
- for line in iter(self._fd.readline, ''):
- self._queue.put(line)
-
- def eof(self):
- '''Check whether there is no more content to expect.'''
- return not self.is_alive() and self._queue.empty()
-
-
class PopenResult:
returncode = None
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.
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)
- stdout_reader.start()
# Check the queue for output (until there is no more to get)
while not stdout_reader.eof():
'project.properties',
'build.properties',
'default.properties',
- 'ant.properties',
- ]:
+ 'ant.properties', ]:
if propfile in files:
path = os.path.join(root, propfile)
paths = env['PATH'].split(os.pathsep)
if path in paths:
return
- paths += path
+ paths.append(path)
env['PATH'] = os.pathsep.join(paths)
-def replace_config_vars(cmd):
+def replace_config_vars(cmd, build):
global env
cmd = cmd.replace('$$SDK$$', config['sdk_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
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
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