1 # -*- coding: utf-8 -*-
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 import glob, os, sys, re
29 from distutils.spawn import find_executable
36 def read_config(opts, config_file='config.py'):
37 """Read the repository config
39 The config is read from config_file, which is in the current directory when
40 any of the repo management commands are used.
42 global config, options
44 if config is not None:
46 if not os.path.isfile(config_file):
47 print "Missing config file - is this a repo directory?"
51 if not hasattr(options, 'verbose'):
52 options.verbose = False
55 'build_server_always': False,
59 'update_stats': False,
62 'stats_to_carbon': False,
73 print "Reading %s..." % config_file
74 execfile(config_file, config)
76 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
77 st = os.stat(config_file)
78 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
79 print "WARNING: unsafe permissions on {0} (should be 0600)!".format(config_file)
81 # Expand environment variables
82 for k, v in config.items():
85 v = os.path.expanduser(v)
86 config[k] = os.path.expandvars(v)
88 # Check that commands and binaries do exist
89 for key in ('mvn3', 'gradle'):
93 executable = find_executable(val)
95 print "ERROR: No such command or binary for %s: %s" % (key, val)
98 # Check that directories exist
99 for key in ('sdk_path', 'ndk_path', 'build_tools'):
100 if key not in config:
103 if key == 'build_tools':
104 if 'sdk_path' not in config:
105 print "ERROR: sdk_path needs to be set for build_tools"
107 val = os.path.join(config['sdk_path'], 'build-tools', val)
108 if not os.path.isdir(val):
109 print "ERROR: No such directory found for %s: %s" % (key, val)
112 for k, v in defconfig.items():
118 # Given the arguments in the form of multiple appid:[vc] strings, this returns
119 # a dictionary with the set of vercodes specified for each package.
120 def read_pkg_args(args, allow_vercodes=False):
127 if allow_vercodes and ':' in p:
128 package, vercode = p.split(':')
130 package, vercode = p, None
131 if package not in vercodes:
132 vercodes[package] = [vercode] if vercode else []
134 elif vercode and vercode not in vercodes[package]:
135 vercodes[package] += [vercode] if vercode else []
139 # On top of what read_pkg_args does, this returns the whole app metadata, but
140 # limiting the builds list to the builds matching the vercodes specified.
141 def read_app_args(args, allapps, allow_vercodes=False):
143 vercodes = read_pkg_args(args, allow_vercodes)
148 apps = [app for app in allapps if app['id'] in vercodes]
151 raise Exception("No packages specified")
152 if len(apps) != len(vercodes):
153 allids = [app["id"] for app in allapps]
156 print "No such package: %s" % p
157 raise Exception("Found invalid app ids in arguments")
161 vc = vercodes[app['id']]
164 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
165 if len(app['builds']) != len(vercodes[app['id']]):
167 allvcs = [b['vercode'] for b in app['builds']]
168 for v in vercodes[app['id']]:
170 print "No such vercode %s for app %s" % (v, app['id'])
173 raise Exception("Found invalid vercodes for some apps")
177 def has_extension(filename, extension):
178 name, ext = os.path.splitext(filename)
179 ext = ext.lower()[1:]
180 return ext == extension
184 def apknameinfo(filename):
186 filename = os.path.basename(filename)
187 if apk_regex is None:
188 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
189 m = apk_regex.match(filename)
191 result = (m.group(1), m.group(2))
192 except AttributeError:
193 raise Exception("Invalid apk name: %s" % filename)
196 def getapkname(app, build):
197 return "%s_%s.apk" % (app['id'], build['vercode'])
199 def getsrcname(app, build):
200 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
204 return '%s (%s)' % (app['Name'], app['id'])
206 return '%s (%s)' % (app['Auto Name'], app['id'])
210 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
212 def getvcs(vcstype, remote, local):
214 return vcs_git(remote, local)
216 return vcs_svn(remote, local)
217 if vcstype == 'git-svn':
218 return vcs_gitsvn(remote, local)
220 return vcs_hg(remote, local)
222 return vcs_bzr(remote, local)
223 if vcstype == 'srclib':
224 if local != 'build/srclib/' + remote:
225 raise VCSException("Error: srclib paths are hard-coded!")
226 return getsrclib(remote, 'build/srclib', raw=True)
227 raise VCSException("Invalid vcs type " + vcstype)
229 def getsrclibvcs(name):
230 srclib_path = os.path.join('srclibs', name + ".txt")
231 if not os.path.exists(srclib_path):
232 raise VCSException("Missing srclib " + name)
233 return metadata.parse_srclib(srclib_path)['Repo Type']
236 def __init__(self, remote, local):
238 # svn, git-svn and bzr may require auth
240 if self.repotype() in ('svn', 'git-svn', 'bzr'):
242 self.username, remote = remote.split('@')
243 if ':' not in self.username:
244 raise VCSException("Password required with username")
245 self.username, self.password = self.username.split(':')
249 self.refreshed = False
252 # Take the local repository to a clean version of the given revision, which
253 # is specificed in the VCS's native format. Beforehand, the repository can
254 # be dirty, or even non-existent. If the repository does already exist
255 # locally, it will be updated from the origin, but only once in the
256 # lifetime of the vcs object.
257 # None is acceptable for 'rev' if you know you are cloning a clean copy of
258 # the repo - otherwise it must specify a valid revision.
259 def gotorevision(self, rev):
261 # The .fdroidvcs-id file for a repo tells us what VCS type
262 # and remote that directory was created from, allowing us to drop it
263 # automatically if either of those things changes.
264 fdpath = os.path.join(self.local, '..',
265 '.fdroidvcs-' + os.path.basename(self.local))
266 cdata = self.repotype() + ' ' + self.remote
269 if os.path.exists(self.local):
270 if os.path.exists(fdpath):
271 with open(fdpath, 'r') as f:
277 print "*** Repository details changed - deleting ***"
280 print "*** Repository details missing - deleting ***"
282 shutil.rmtree(self.local)
284 self.gotorevisionx(rev)
286 # If necessary, write the .fdroidvcs file.
288 with open(fdpath, 'w') as f:
291 # Derived classes need to implement this. It's called once basic checking
292 # has been performend.
293 def gotorevisionx(self, rev):
294 raise VCSException("This VCS type doesn't define gotorevisionx")
296 # Initialise and update submodules
297 def initsubmodules(self):
298 raise VCSException('Submodules not supported for this vcs type')
300 # Get a list of all known tags
302 raise VCSException('gettags not supported for this vcs type')
304 # Get current commit reference (hash, revision, etc)
306 raise VCSException('getref not supported for this vcs type')
308 # Returns the srclib (name, path) used in setting up the current
318 # If the local directory exists, but is somehow not a git repository, git
319 # will traverse up the directory tree until it finds one that is (i.e.
320 # fdroidserver) and then we'll proceed to destroy it! This is called as
323 p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
324 stdout=subprocess.PIPE, cwd=self.local)
325 result = p.communicate()[0].rstrip()
326 if not result.endswith(self.local):
327 raise VCSException('Repository mismatch')
329 def gotorevisionx(self, rev):
330 if not os.path.exists(self.local):
331 # Brand new checkout...
332 if subprocess.call(['git', 'clone', self.remote, self.local]) != 0:
333 raise VCSException("Git clone failed")
337 # Discard any working tree changes...
338 if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
339 raise VCSException("Git reset failed")
340 # Remove untracked files now, in case they're tracked in the target
341 # revision (it happens!)...
342 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
343 raise VCSException("Git clean failed")
344 if not self.refreshed:
345 # Get latest commits and tags from remote...
346 if subprocess.call(['git', 'fetch', 'origin'],
347 cwd=self.local) != 0:
348 raise VCSException("Git fetch failed")
349 if subprocess.call(['git', 'fetch', '--tags', 'origin'],
350 cwd=self.local) != 0:
351 raise VCSException("Git fetch failed")
352 self.refreshed = True
353 # Check out the appropriate revision...
354 rev = str(rev if rev else 'origin/master')
355 if subprocess.call(['git', 'checkout', '-f', rev], cwd=self.local) != 0:
356 raise VCSException("Git checkout failed")
357 # Get rid of any uncontrolled files left behind...
358 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
359 raise VCSException("Git clean failed")
361 def initsubmodules(self):
363 if subprocess.call(['git', 'submodule', 'init'],
364 cwd=self.local) != 0:
365 raise VCSException("Git submodule init failed")
366 if subprocess.call(['git', 'submodule', 'update'],
367 cwd=self.local) != 0:
368 raise VCSException("Git submodule update failed")
369 if subprocess.call(['git', 'submodule', 'foreach',
370 'git', 'reset', '--hard'],
371 cwd=self.local) != 0:
372 raise VCSException("Git submodule reset failed")
373 if subprocess.call(['git', 'submodule', 'foreach',
374 'git', 'clean', '-dffx'],
375 cwd=self.local) != 0:
376 raise VCSException("Git submodule clean failed")
380 p = subprocess.Popen(['git', 'tag'],
381 stdout=subprocess.PIPE, cwd=self.local)
382 return p.communicate()[0].splitlines()
385 class vcs_gitsvn(vcs):
390 # Damn git-svn tries to use a graphical password prompt, so we have to
391 # trick it into taking the password from stdin
393 if self.username is None:
395 return ('echo "%s" | DISPLAY="" ' % self.password, '--username "%s"' % self.username)
397 # If the local directory exists, but is somehow not a git repository, git
398 # will traverse up the directory tree until it finds one that is (i.e.
399 # fdroidserver) and then we'll proceed to destory it! This is called as
402 p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
403 stdout=subprocess.PIPE, cwd=self.local)
404 result = p.communicate()[0].rstrip()
405 if not result.endswith(self.local):
406 raise VCSException('Repository mismatch')
408 def gotorevisionx(self, rev):
409 if not os.path.exists(self.local):
410 # Brand new checkout...
411 gitsvn_cmd = '%sgit svn clone %s' % self.userargs()
412 if ';' in self.remote:
413 remote_split = self.remote.split(';')
414 for i in remote_split[1:]:
415 if i.startswith('trunk='):
416 gitsvn_cmd += ' -T %s' % i[6:]
417 elif i.startswith('tags='):
418 gitsvn_cmd += ' -t %s' % i[5:]
419 elif i.startswith('branches='):
420 gitsvn_cmd += ' -b %s' % i[9:]
421 if subprocess.call([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)],
423 raise VCSException("Git clone failed")
425 if subprocess.call([gitsvn_cmd + " %s %s" % (self.remote, self.local)],
427 raise VCSException("Git clone failed")
431 # Discard any working tree changes...
432 if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
433 raise VCSException("Git reset failed")
434 # Remove untracked files now, in case they're tracked in the target
435 # revision (it happens!)...
436 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
437 raise VCSException("Git clean failed")
438 if not self.refreshed:
439 # Get new commits and tags from repo...
440 if subprocess.call(['%sgit svn rebase %s' % self.userargs()],
441 cwd=self.local, shell=True) != 0:
442 raise VCSException("Git svn rebase failed")
443 self.refreshed = True
445 rev = str(rev if rev else 'master')
447 nospaces_rev = rev.replace(' ', '%20')
448 # Try finding a svn tag
449 p = subprocess.Popen(['git', 'checkout', 'tags/' + nospaces_rev],
450 cwd=self.local, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
451 out, err = p.communicate()
452 if p.returncode == 0:
455 # No tag found, normal svn rev translation
456 # Translate svn rev into git format
457 p = subprocess.Popen(['git', 'svn', 'find-rev', 'r' + rev],
458 cwd=self.local, stdout=subprocess.PIPE)
459 git_rev = p.communicate()[0].rstrip()
460 if p.returncode != 0 or not git_rev:
461 # Try a plain git checkout as a last resort
462 p = subprocess.Popen(['git', 'checkout', rev], cwd=self.local,
463 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
464 out, err = p.communicate()
465 if p.returncode == 0:
468 raise VCSException("No git treeish found and direct git checkout failed")
470 # Check out the git rev equivalent to the svn rev
471 p = subprocess.Popen(['git', 'checkout', git_rev], cwd=self.local,
472 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
473 out, err = p.communicate()
474 if p.returncode == 0:
477 raise VCSException("Git svn checkout failed")
478 # Get rid of any uncontrolled files left behind...
479 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
480 raise VCSException("Git clean failed")
484 return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
488 p = subprocess.Popen(['git', 'svn', 'find-rev', 'HEAD'],
489 stdout=subprocess.PIPE, cwd=self.local)
490 return p.communicate()[0].strip()
498 if self.username is None:
499 return ['--non-interactive']
500 return ['--username', self.username,
501 '--password', self.password,
504 def gotorevisionx(self, rev):
505 if not os.path.exists(self.local):
506 if subprocess.call(['svn', 'checkout', self.remote, self.local] +
507 self.userargs()) != 0:
508 raise VCSException("Svn checkout failed")
512 r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
513 if subprocess.call(svncommand, cwd=self.local, shell=True) != 0:
514 raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local))
515 if not self.refreshed:
516 if subprocess.call(['svn', 'update'] +
517 self.userargs(), cwd=self.local) != 0:
518 raise VCSException("Svn update failed")
519 self.refreshed = True
521 revargs = list(['-r', rev] if rev else [])
522 if subprocess.call(['svn', 'update', '--force'] + revargs +
523 self.userargs(), cwd=self.local) != 0:
524 raise VCSException("Svn update failed")
527 p = subprocess.Popen(['svn', 'info'],
528 stdout=subprocess.PIPE, cwd=self.local)
529 for line in p.communicate()[0].splitlines():
530 if line and line.startswith('Last Changed Rev: '):
538 def gotorevisionx(self, rev):
539 if not os.path.exists(self.local):
540 if subprocess.call(['hg', 'clone', self.remote, self.local]) !=0:
541 raise VCSException("Hg clone failed")
543 if subprocess.call('hg status -uS | xargs rm -rf',
544 cwd=self.local, shell=True) != 0:
545 raise VCSException("Hg clean failed")
546 if not self.refreshed:
547 if subprocess.call(['hg', 'pull'],
548 cwd=self.local) != 0:
549 raise VCSException("Hg pull failed")
550 self.refreshed = True
552 rev = str(rev if rev else 'default')
555 if subprocess.call(['hg', 'update', '-C', rev],
556 cwd=self.local) != 0:
557 raise VCSException("Hg checkout failed")
560 p = subprocess.Popen(['hg', 'tags', '-q'],
561 stdout=subprocess.PIPE, cwd=self.local)
562 return p.communicate()[0].splitlines()[1:]
570 def gotorevisionx(self, rev):
571 if not os.path.exists(self.local):
572 if subprocess.call(['bzr', 'branch', self.remote, self.local]) != 0:
573 raise VCSException("Bzr branch failed")
575 if subprocess.call(['bzr', 'clean-tree', '--force',
576 '--unknown', '--ignored'], cwd=self.local) != 0:
577 raise VCSException("Bzr revert failed")
578 if not self.refreshed:
579 if subprocess.call(['bzr', 'pull'],
580 cwd=self.local) != 0:
581 raise VCSException("Bzr update failed")
582 self.refreshed = True
584 revargs = list(['-r', rev] if rev else [])
585 if subprocess.call(['bzr', 'revert'] + revargs,
586 cwd=self.local) != 0:
587 raise VCSException("Bzr revert failed")
590 p = subprocess.Popen(['bzr', 'tags'],
591 stdout=subprocess.PIPE, cwd=self.local)
592 return [tag.split(' ')[0].strip() for tag in
593 p.communicate()[0].splitlines()]
595 def retrieve_string(xml_dir, string):
596 if string.startswith('@string/'):
597 string_search = re.compile(r'.*"'+string[8:]+'".*?>([^<]+?)<.*').search
598 for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
599 for line in file(xmlfile):
600 matches = string_search(line)
602 return retrieve_string(xml_dir, matches.group(1))
603 elif string.startswith('&') and string.endswith(';'):
604 string_search = re.compile(r'.*<!ENTITY.*'+string[1:-1]+'.*?"([^"]+?)".*>').search
605 for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
606 for line in file(xmlfile):
607 matches = string_search(line)
609 return retrieve_string(xml_dir, matches.group(1))
611 return string.replace("\\'","'")
613 # Return list of existing files that will be used to find the highest vercode
614 def manifest_paths(app_dir, flavour):
616 possible_manifests = [ os.path.join(app_dir, 'AndroidManifest.xml'),
617 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
618 os.path.join(app_dir, 'build.gradle') ]
621 possible_manifests.append(
622 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
624 return [path for path in possible_manifests if os.path.isfile(path)]
626 # Retrieve the package name
627 def fetch_real_name(app_dir, flavour):
628 app_search = re.compile(r'.*<application.*').search
629 name_search = re.compile(r'.*android:label="([^"]+)".*').search
631 for f in manifest_paths(app_dir, flavour):
632 if not has_extension(f, 'xml'):
634 xml_dir = os.path.join(f[:-19], 'res', 'values')
640 matches = name_search(line)
642 return retrieve_string(xml_dir, matches.group(1))
645 # Retrieve the version name
646 def version_name(original, app_dir, flavour):
647 for f in manifest_paths(app_dir, flavour):
648 if not has_extension(f, 'xml'):
650 xml_dir = os.path.join(f[:-19], 'res', 'values')
651 string = retrieve_string(xml_dir, original)
656 def ant_subprojects(root_dir):
658 proppath = os.path.join(root_dir, 'project.properties')
659 if not os.path.isfile(proppath):
661 with open(proppath) as f:
662 for line in f.readlines():
663 if not line.startswith('android.library.reference.'):
665 path = line.split('=')[1].strip()
666 relpath = os.path.join(root_dir, path)
667 if not os.path.isdir(relpath):
670 print "Found subproject %s..." % path
671 subprojects.append(path)
674 # Extract some information from the AndroidManifest.xml at the given path.
675 # Returns (version, vercode, package), any or all of which might be None.
676 # All values returned are strings.
677 def parse_androidmanifests(paths):
680 return (None, None, None)
682 vcsearch = re.compile(r'.*android:versionCode="([0-9]+?)".*').search
683 vnsearch = re.compile(r'.*android:versionName="([^"]+?)".*').search
684 psearch = re.compile(r'.*package="([^"]+)".*').search
686 vcsearch_g = re.compile(r'.*versionCode[ ]*=[ ]*["\']*([0-9]+?)[^\d].*').search
687 vnsearch_g = re.compile(r'.*versionName[ ]*=[ ]*(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
688 psearch_g = re.compile(r'.*packageName[ ]*=[ ]*["\']([^"]+)["\'].*').search
696 gradle = has_extension(path, 'gradle')
699 # Remember package name, may be defined separately from version+vercode
700 package = max_package
702 for line in file(path):
705 matches = psearch_g(line)
707 matches = psearch(line)
709 package = matches.group(1)
712 matches = vnsearch_g(line)
714 matches = vnsearch(line)
716 version = matches.group(2 if gradle else 1)
719 matches = vcsearch_g(line)
721 matches = vcsearch(line)
723 vercode = matches.group(1)
725 # Better some package name than nothing
726 if max_package is None:
727 max_package = package
729 if max_vercode is None or (vercode is not None and vercode > max_vercode):
730 max_version = version
731 max_vercode = vercode
732 max_package = package
734 if max_version is None:
735 max_version = "Unknown"
737 return (max_version, max_vercode, max_package)
739 class BuildException(Exception):
740 def __init__(self, value, stdout = None, stderr = None):
745 def get_wikitext(self):
746 ret = repr(self.value) + "\n"
750 ret += str(self.stdout)
755 ret += str(self.stderr)
760 ret = repr(self.value)
762 ret += "\n==== stdout begin ====\n%s\n==== stdout end ====" % self.stdout.strip()
764 ret += "\n==== stderr begin ====\n%s\n==== stderr end ====" % self.stderr.strip()
767 class VCSException(Exception):
768 def __init__(self, value):
772 return repr(self.value)
774 # Get the specified source library.
775 # Returns the path to it. Normally this is the path to be used when referencing
776 # it, which may be a subdirectory of the actual project. If you want the base
777 # directory of the project, pass 'basepath=True'.
778 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None, target=None,
779 basepath=False, raw=False, prepare=True, preponly=False):
787 name, ref = spec.split('@')
789 number, name = name.split(':', 1)
791 name, subdir = name.split('/',1)
793 srclib_path = os.path.join('srclibs', name + ".txt")
795 if not os.path.exists(srclib_path):
796 raise BuildException('srclib ' + name + ' not found.')
798 srclib = metadata.parse_srclib(srclib_path)
800 sdir = os.path.join(srclib_dir, name)
803 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
804 vcs.srclib = (name, number, sdir)
806 vcs.gotorevision(ref)
813 libdir = os.path.join(sdir, subdir)
814 elif srclib["Subdir"]:
815 for subdir in srclib["Subdir"]:
816 libdir_candidate = os.path.join(sdir, subdir)
817 if os.path.exists(libdir_candidate):
818 libdir = libdir_candidate
824 if srclib["Srclibs"]:
826 for lib in srclib["Srclibs"].split(','):
828 for t in srclibpaths:
833 raise BuildException('Missing recursive srclib %s for %s' % (
835 place_srclib(libdir, n, s_tuple[2])
840 if srclib["Prepare"]:
841 cmd = replace_config_vars(srclib["Prepare"])
843 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
844 if p.returncode != 0:
845 raise BuildException("Error running prepare command for srclib %s"
846 % name, p.stdout, p.stderr)
848 if srclib["Update Project"] == "Yes":
849 print "Updating srclib %s at path %s" % (name, libdir)
850 cmd = [os.path.join(config['sdk_path'], 'tools', 'android'),
851 'update', 'project', '-p', libdir]
853 cmd += ['-t', target]
855 # Check to see whether an error was returned without a proper exit
856 # code (this is the case for the 'no target set or target invalid'
858 if p.returncode != 0 or (p.stderr != "" and
859 p.stderr.startswith("Error: ")):
860 raise BuildException("Failed to update srclib project {0}"
861 .format(name), p.stdout, p.stderr)
863 remove_signing_keys(libdir)
868 return (name, number, libdir)
871 # Prepare the source code for a particular build
872 # 'vcs' - the appropriate vcs object for the application
873 # 'app' - the application details from the metadata
874 # 'build' - the build details from the metadata
875 # 'build_dir' - the path to the build directory, usually
877 # 'srclib_dir' - the path to the source libraries directory, usually
879 # 'extlib_dir' - the path to the external libraries directory, usually
881 # Returns the (root, srclibpaths) where:
882 # 'root' is the root directory, which may be the same as 'build_dir' or may
883 # be a subdirectory of it.
884 # 'srclibpaths' is information on the srclibs being used
885 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
887 # Optionally, the actual app source can be in a subdirectory...
888 if 'subdir' in build:
889 root_dir = os.path.join(build_dir, build['subdir'])
893 # Get a working copy of the right revision...
894 print "Getting source for revision " + build['commit']
895 vcs.gotorevision(build['commit'])
897 # Check that a subdir (if we're using one) exists. This has to happen
898 # after the checkout, since it might not exist elsewhere...
899 if not os.path.exists(root_dir):
900 raise BuildException('Missing subdir ' + root_dir)
902 # Initialise submodules if requred...
903 if build['submodules']:
905 print "Initialising submodules..."
908 # Run an init command if one is required...
910 cmd = replace_config_vars(build['init'])
912 print "Running 'init' commands in %s" % root_dir
914 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
915 if p.returncode != 0:
916 raise BuildException("Error running init command for %s:%s" %
917 (app['id'], build['version']), p.stdout, p.stderr)
919 # Generate (or update) the ant build file, build.xml...
920 updatemode = build.get('update', 'auto')
921 if (updatemode != 'no'
922 and build.get('maven', 'no') == 'no'
923 and build.get('kivy', 'no') == 'no'
924 and build.get('gradle', 'no') == 'no'):
925 parms = [os.path.join(config['sdk_path'], 'tools', 'android'),
927 if 'target' in build and build['target']:
928 parms += ['-t', build['target']]
930 if updatemode == 'auto':
931 update_dirs = ['.'] + ant_subprojects(root_dir)
933 update_dirs = [d.strip() for d in updatemode.split(';')]
934 # Force build.xml update if necessary...
935 if updatemode == 'force' or 'target' in build:
936 if updatemode == 'force':
938 buildxml = os.path.join(root_dir, 'build.xml')
939 if os.path.exists(buildxml):
940 print 'Force-removing old build.xml'
943 for d in update_dirs:
944 subdir = os.path.join(root_dir, d)
945 # Clean update dirs via ant
946 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
947 dparms = parms + ['-p', d]
950 print "Updating main project..."
952 print "Updating subproject %s..." % d
953 p = FDroidPopen(dparms, cwd=root_dir)
954 # Check to see whether an error was returned without a proper exit
955 # code (this is the case for the 'no target set or target invalid'
957 if p.returncode != 0 or (p.stderr != "" and
958 p.stderr.startswith("Error: ")):
959 raise BuildException("Failed to update project at %s" % d,
962 # Update the local.properties file...
963 localprops = [ os.path.join(build_dir, 'local.properties') ]
964 if 'subdir' in build:
965 localprops += [ os.path.join(root_dir, 'local.properties') ]
966 for path in localprops:
967 if not os.path.isfile(path):
970 print "Updating properties file at %s" % path
975 # Fix old-fashioned 'sdk-location' by copying
976 # from sdk.dir, if necessary...
977 if build['oldsdkloc']:
978 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
980 props += "sdk-location=%s\n" % sdkloc
982 props += "sdk.dir=%s\n" % config['sdk_path']
983 props += "sdk-location=%s\n" % ['sdk_path']
984 # Add ndk location...
985 props += "ndk.dir=%s\n" % config['ndk_path']
986 props += "ndk-location=%s\n" % config['ndk_path']
987 # Add java.encoding if necessary...
988 if 'encoding' in build:
989 props += "java.encoding=%s\n" % build['encoding']
995 if build.get('gradle', 'no') != 'no':
996 flavour = build['gradle'].split('@')[0]
997 if flavour in ['main', 'yes', '']:
1000 # Remove forced debuggable flags
1001 print "Removing debuggable flags..."
1002 for path in manifest_paths(root_dir, flavour):
1003 if not os.path.isfile(path):
1005 if subprocess.call(['sed','-i',
1006 's/android:debuggable="[^"]*"//g', path]) != 0:
1007 raise BuildException("Failed to remove debuggable flags")
1009 # Insert version code and number into the manifest if necessary...
1010 if build['forceversion']:
1011 print "Changing the version name..."
1012 for path in manifest_paths(root_dir, flavour):
1013 if not os.path.isfile(path):
1015 if has_extension(path, 'xml'):
1016 if subprocess.call(['sed','-i',
1017 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1019 raise BuildException("Failed to amend manifest")
1020 elif has_extension(path, 'gradle'):
1021 if subprocess.call(['sed','-i',
1022 's/versionName[ ]*=[ ]*"[^"]*"/versionName = "' + build['version'] + '"/g',
1024 raise BuildException("Failed to amend build.gradle")
1025 if build['forcevercode']:
1026 print "Changing the version code..."
1027 for path in manifest_paths(root_dir, flavour):
1028 if not os.path.isfile(path):
1030 if has_extension(path, 'xml'):
1031 if subprocess.call(['sed','-i',
1032 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1034 raise BuildException("Failed to amend manifest")
1035 elif has_extension(path, 'gradle'):
1036 if subprocess.call(['sed','-i',
1037 's/versionCode[ ]*=[ ]*[0-9]*/versionCode = ' + build['vercode'] + '/g',
1039 raise BuildException("Failed to amend build.gradle")
1041 # Delete unwanted files...
1043 for part in build['rm'].split(';'):
1044 dest = os.path.join(build_dir, part.strip())
1045 rdest = os.path.abspath(dest)
1047 print "Removing {0}".format(rdest)
1048 if not rdest.startswith(os.path.abspath(build_dir)):
1049 raise BuildException("rm for {1} is outside build root {0}".format(
1050 os.path.abspath(build_dir),os.path.abspath(dest)))
1051 if rdest == os.path.abspath(build_dir):
1052 raise BuildException("rm removes whole build directory")
1053 if os.path.lexists(rdest):
1054 if os.path.islink(rdest):
1055 subprocess.call('unlink ' + rdest, shell=True)
1057 subprocess.call('rm -rf ' + rdest, shell=True)
1060 print "...but it didn't exist"
1062 # Fix apostrophes translation files if necessary...
1063 if build['fixapos']:
1064 for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1065 for filename in files:
1066 if has_extension(filename, 'xml'):
1067 if subprocess.call(['sed','-i','s@' +
1068 r"\([^\\]\)'@\1\\'" +
1070 os.path.join(root, filename)]) != 0:
1071 raise BuildException("Failed to amend " + filename)
1073 # Fix translation files if necessary...
1074 if build['fixtrans']:
1075 for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1076 for filename in files:
1077 if has_extension(filename, 'xml'):
1078 f = open(os.path.join(root, filename))
1086 index = line.find("%", index)
1089 next = line[index+1:index+2]
1090 if next == "s" or next == "d":
1091 line = (line[:index+1] +
1098 # We only want to insert the positional arguments
1099 # when there is more than one argument...
1105 outlines.append(line)
1108 f = open(os.path.join(root, filename), 'w')
1109 f.writelines(outlines)
1112 remove_signing_keys(build_dir)
1114 # Add required external libraries...
1115 if 'extlibs' in build:
1116 print "Collecting prebuilt libraries..."
1117 libsdir = os.path.join(root_dir, 'libs')
1118 if not os.path.exists(libsdir):
1120 for lib in build['extlibs'].split(';'):
1123 print "...installing extlib {0}".format(lib)
1124 libf = os.path.basename(lib)
1125 libsrc = os.path.join(extlib_dir, lib)
1126 if not os.path.exists(libsrc):
1127 raise BuildException("Missing extlib file {0}".format(libsrc))
1128 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1130 # Get required source libraries...
1132 if 'srclibs' in build:
1133 target=build['target'] if 'target' in build else None
1134 print "Collecting source libraries..."
1135 for lib in build['srclibs'].split(';'):
1136 srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1137 target=target, preponly=onserver))
1139 # Apply patches if any
1140 if 'patch' in build:
1141 for patch in build['patch'].split(';'):
1142 patch = patch.strip()
1143 print "Applying " + patch
1144 patch_path = os.path.join('metadata', app['id'], patch)
1145 if subprocess.call(['patch', '-p1',
1146 '-i', os.path.abspath(patch_path)], cwd=build_dir) != 0:
1147 raise BuildException("Failed to apply patch %s" % patch_path)
1149 for name, number, libpath in srclibpaths:
1150 place_srclib(root_dir, int(number) if number else None, libpath)
1152 basesrclib = vcs.getsrclib()
1153 # If one was used for the main source, add that too.
1155 srclibpaths.append(basesrclib)
1157 # Run a pre-build command if one is required...
1158 if 'prebuild' in build:
1159 cmd = replace_config_vars(build['prebuild'])
1161 # Substitute source library paths into prebuild commands...
1162 for name, number, libpath in srclibpaths:
1163 libpath = os.path.relpath(libpath, root_dir)
1164 cmd = cmd.replace('$$' + name + '$$', libpath)
1167 print "Running 'prebuild' commands in %s" % root_dir
1169 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1170 if p.returncode != 0:
1171 raise BuildException("Error running prebuild command for %s:%s" %
1172 (app['id'], build['version']), p.stdout, p.stderr)
1174 return (root_dir, srclibpaths)
1176 # Scan the source code in the given directory (and all subdirectories)
1177 # and return a list of potential problems.
1178 def scan_source(build_dir, root_dir, thisbuild):
1182 # Common known non-free blobs (always lower case):
1183 usual_suspects = ['flurryagent',
1185 'libgoogleanalytics',
1186 'admob-sdk-android',
1188 'googleadmobadssdk',
1189 'google-play-services',
1193 'youtubeandroidplayerapi',
1198 def getpaths(field):
1200 if field not in thisbuild:
1202 for p in thisbuild[field].split(';'):
1206 elif p.startswith('./'):
1208 elif not p.startswith('/'):
1214 scanignore = getpaths('scanignore')
1215 scandelete = getpaths('scandelete')
1217 ms = magic.open(magic.MIME_TYPE)
1221 for i in scanignore:
1222 if fd.startswith(i):
1227 for i in scandelete:
1228 if fd.startswith(i):
1232 def removeproblem(what, fd, fp):
1233 print 'Removing %s at %s' % (what, fd)
1236 def handleproblem(what, fd, fp):
1238 removeproblem(what, fd, fp)
1240 problems.append('Found %s at %s' % (what, fd))
1242 def warnproblem(what, fd, fp):
1243 print 'Warning: Found %s at %s' % (what, fd)
1245 # Iterate through all files in the source code...
1246 for r,d,f in os.walk(build_dir):
1249 if '/.hg' in r or '/.git' in r or '/.svn' in r:
1252 # Path (relative) to the file...
1253 fp = os.path.join(r, curfile)
1254 fd = fp[len(build_dir):]
1256 # Check if this file has been explicitly excluded from scanning...
1260 for suspect in usual_suspects:
1261 if suspect in curfile.lower():
1262 handleproblem('usual supect', fd, fp)
1265 if mime == 'application/x-sharedlib':
1266 handleproblem('shared library', fd, fp)
1267 elif mime == 'application/x-archive':
1268 handleproblem('static library', fd, fp)
1269 elif mime == 'application/x-executable':
1270 handleproblem('binary executable', fd, fp)
1271 elif mime == 'application/jar' and has_extension(fp, 'apk'):
1272 removeproblem('APK file', fd, fp)
1273 elif mime == 'application/jar' and has_extension(fp, 'jar'):
1274 warnproblem('JAR file', fd, fp)
1276 elif has_extension(fp, 'java'):
1277 for line in file(fp):
1278 if 'DexClassLoader' in line:
1279 handleproblem('DexClassLoader', fd, fp)
1283 # Presence of a jni directory without buildjni=yes might
1284 # indicate a problem... (if it's not a problem, explicitly use
1285 # buildjni=no to bypass this check)
1286 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1287 thisbuild.get('buildjni') is None):
1288 msg = 'Found jni directory, but buildjni is not enabled'
1289 problems.append(msg)
1297 self.path = os.path.join('stats', 'known_apks.txt')
1299 if os.path.exists(self.path):
1300 for line in file( self.path):
1301 t = line.rstrip().split(' ')
1303 self.apks[t[0]] = (t[1], None)
1305 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1306 self.changed = False
1308 def writeifchanged(self):
1310 if not os.path.exists('stats'):
1312 f = open(self.path, 'w')
1314 for apk, app in self.apks.iteritems():
1316 line = apk + ' ' + appid
1318 line += ' ' + time.strftime('%Y-%m-%d', added)
1320 for line in sorted(lst):
1321 f.write(line + '\n')
1324 # Record an apk (if it's new, otherwise does nothing)
1325 # Returns the date it was added.
1326 def recordapk(self, apk, app):
1327 if not apk in self.apks:
1328 self.apks[apk] = (app, time.gmtime(time.time()))
1330 _, added = self.apks[apk]
1333 # Look up information - given the 'apkname', returns (app id, date added/None).
1334 # Or returns None for an unknown apk.
1335 def getapp(self, apkname):
1336 if apkname in self.apks:
1337 return self.apks[apkname]
1340 # Get the most recent 'num' apps added to the repo, as a list of package ids
1341 # with the most recent first.
1342 def getlatest(self, num):
1344 for apk, app in self.apks.iteritems():
1348 if apps[appid] > added:
1352 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1353 lst = [app for app,added in sortedapps]
1357 def isApkDebuggable(apkfile, config):
1358 """Returns True if the given apk file is debuggable
1360 :param apkfile: full path to the apk to check"""
1362 p = subprocess.Popen([os.path.join(config['sdk_path'],
1363 'build-tools', config['build_tools'], 'aapt'),
1364 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1365 stdout=subprocess.PIPE)
1366 output = p.communicate()[0]
1367 if p.returncode != 0:
1368 print "ERROR: Failed to get apk manifest information"
1370 for line in output.splitlines():
1371 if 'android:debuggable' in line and not line.endswith('0x0'):
1376 class AsynchronousFileReader(threading.Thread):
1378 Helper class to implement asynchronous reading of a file
1379 in a separate thread. Pushes read lines on a queue to
1380 be consumed in another thread.
1383 def __init__(self, fd, queue):
1384 assert isinstance(queue, Queue.Queue)
1385 assert callable(fd.readline)
1386 threading.Thread.__init__(self)
1391 '''The body of the tread: read lines and put them on the queue.'''
1392 for line in iter(self._fd.readline, ''):
1393 self._queue.put(line)
1396 '''Check whether there is no more content to expect.'''
1397 return not self.is_alive() and self._queue.empty()
1405 def FDroidPopen(commands, cwd=None):
1407 Runs a command the FDroid way and returns return code and output
1409 :param commands, cwd: like subprocess.Popen
1414 print "Directory: %s" % cwd
1415 print " > %s" % ' '.join(commands)
1417 result = PopenResult()
1418 p = subprocess.Popen(commands, cwd=cwd,
1419 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1421 stdout_queue = Queue.Queue()
1422 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1423 stdout_reader.start()
1424 stderr_queue = Queue.Queue()
1425 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1426 stderr_reader.start()
1428 # Check the queues for output (until there is no more to get)
1429 while not stdout_reader.eof() or not stderr_reader.eof():
1430 # Show what we received from standard output
1431 while not stdout_queue.empty():
1432 line = stdout_queue.get()
1434 # Output directly to console
1435 sys.stdout.write(line)
1437 result.stdout += line
1439 # Show what we received from standard error
1440 while not stderr_queue.empty():
1441 line = stderr_queue.get()
1443 # Output directly to console
1444 sys.stderr.write(line)
1446 result.stderr += line
1450 result.returncode = p.returncode
1453 def remove_signing_keys(build_dir):
1454 for root, dirs, files in os.walk(build_dir):
1455 if 'build.gradle' in files:
1456 path = os.path.join(root, 'build.gradle')
1459 with open(path, "r") as o:
1460 lines = o.readlines()
1463 with open(path, "w") as o:
1465 if 'signingConfigs ' in line:
1473 elif any(s in line for s in (
1475 'android.signingConfigs.',
1476 'variant.outputFile = ',
1482 if changed and options.verbose:
1483 print "Cleaned build.gradle of keysigning configs at %s" % path
1485 for propfile in ('build.properties', 'default.properties', 'ant.properties'):
1486 if propfile in files:
1487 path = os.path.join(root, propfile)
1490 with open(path, "r") as o:
1491 lines = o.readlines()
1493 with open(path, "w") as o:
1495 if line.startswith('key.store'):
1500 if changed and options.verbose:
1501 print "Cleaned %s of keysigning configs at %s" % (propfile,path)
1503 def replace_config_vars(cmd):
1504 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1505 cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1506 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1509 def place_srclib(root_dir, number, libpath):
1512 relpath = os.path.relpath(libpath, root_dir)
1513 proppath = os.path.join(root_dir, 'project.properties')
1515 with open(proppath, "r") as o:
1516 lines = o.readlines()
1518 with open(proppath, "w") as o:
1521 if line.startswith('android.library.reference.%d=' % number):
1522 o.write('android.library.reference.%d=%s\n' % (number,relpath))
1527 o.write('android.library.reference.%d=%s\n' % (number,relpath))