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,
68 print "Reading %s..." % config_file
69 execfile(config_file, config)
71 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
72 st = os.stat(config_file)
73 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
74 print "WARNING: unsafe permissions on {0} (should be 0600)!".format(config_file)
76 # Expand environment variables
77 for k, v in config.items():
80 v = os.path.expanduser(v)
81 config[k] = os.path.expandvars(v)
83 # Check that commands and binaries do exist
84 for key in ('mvn3', 'gradle'):
88 executable = find_executable(val)
90 print "ERROR: No such command or binary for %s: %s" % (key, val)
93 # Check that directories exist
94 for key in ('sdk_path', 'ndk_path', 'build_tools'):
98 if key == 'build_tools':
99 if 'sdk_path' not in config:
100 print "ERROR: sdk_path needs to be set for build_tools"
102 val = os.path.join(config['sdk_path'], 'build-tools', val)
103 if not os.path.isdir(val):
104 print "ERROR: No such directory found for %s: %s" % (key, val)
107 for k, v in defconfig.items():
113 def getapkname(app, build):
114 return "%s_%s.apk" % (app['id'], build['vercode'])
116 def getsrcname(app, build):
117 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
121 return '%s (%s)' % (app['Name'], app['id'])
123 return '%s (%s)' % (app['Auto Name'], app['id'])
127 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
129 def getvcs(vcstype, remote, local):
131 return vcs_git(remote, local)
133 return vcs_svn(remote, local)
134 if vcstype == 'git-svn':
135 return vcs_gitsvn(remote, local)
137 return vcs_hg(remote, local)
139 return vcs_bzr(remote, local)
140 if vcstype == 'srclib':
141 if local != 'build/srclib/' + remote:
142 raise VCSException("Error: srclib paths are hard-coded!")
143 return getsrclib(remote, 'build/srclib', raw=True)
144 raise VCSException("Invalid vcs type " + vcstype)
146 def getsrclibvcs(name):
147 srclib_path = os.path.join('srclibs', name + ".txt")
148 if not os.path.exists(srclib_path):
149 raise VCSException("Missing srclib " + name)
150 return metadata.parse_srclib(srclib_path)['Repo Type']
153 def __init__(self, remote, local):
155 # svn, git-svn and bzr may require auth
157 if self.repotype() in ('svn', 'git-svn', 'bzr'):
159 self.username, remote = remote.split('@')
160 if ':' not in self.username:
161 raise VCSException("Password required with username")
162 self.username, self.password = self.username.split(':')
166 self.refreshed = False
169 # Take the local repository to a clean version of the given revision, which
170 # is specificed in the VCS's native format. Beforehand, the repository can
171 # be dirty, or even non-existent. If the repository does already exist
172 # locally, it will be updated from the origin, but only once in the
173 # lifetime of the vcs object.
174 # None is acceptable for 'rev' if you know you are cloning a clean copy of
175 # the repo - otherwise it must specify a valid revision.
176 def gotorevision(self, rev):
178 # The .fdroidvcs-id file for a repo tells us what VCS type
179 # and remote that directory was created from, allowing us to drop it
180 # automatically if either of those things changes.
181 fdpath = os.path.join(self.local, '..',
182 '.fdroidvcs-' + os.path.basename(self.local))
183 cdata = self.repotype() + ' ' + self.remote
186 if os.path.exists(self.local):
187 if os.path.exists(fdpath):
188 with open(fdpath, 'r') as f:
194 print "*** Repository details changed - deleting ***"
197 print "*** Repository details missing - deleting ***"
199 shutil.rmtree(self.local)
201 self.gotorevisionx(rev)
203 # If necessary, write the .fdroidvcs file.
205 with open(fdpath, 'w') as f:
208 # Derived classes need to implement this. It's called once basic checking
209 # has been performend.
210 def gotorevisionx(self, rev):
211 raise VCSException("This VCS type doesn't define gotorevisionx")
213 # Initialise and update submodules
214 def initsubmodules(self):
215 raise VCSException('Submodules not supported for this vcs type')
217 # Get a list of all known tags
219 raise VCSException('gettags not supported for this vcs type')
221 # Get current commit reference (hash, revision, etc)
223 raise VCSException('getref not supported for this vcs type')
225 # Returns the srclib (name, path) used in setting up the current
235 # If the local directory exists, but is somehow not a git repository, git
236 # will traverse up the directory tree until it finds one that is (i.e.
237 # fdroidserver) and then we'll proceed to destroy it! This is called as
240 p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
241 stdout=subprocess.PIPE, cwd=self.local)
242 result = p.communicate()[0].rstrip()
243 if not result.endswith(self.local):
244 raise VCSException('Repository mismatch')
246 def gotorevisionx(self, rev):
247 if not os.path.exists(self.local):
248 # Brand new checkout...
249 if subprocess.call(['git', 'clone', self.remote, self.local]) != 0:
250 raise VCSException("Git clone failed")
254 # Discard any working tree changes...
255 if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
256 raise VCSException("Git reset failed")
257 # Remove untracked files now, in case they're tracked in the target
258 # revision (it happens!)...
259 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
260 raise VCSException("Git clean failed")
261 if not self.refreshed:
262 # Get latest commits and tags from remote...
263 if subprocess.call(['git', 'fetch', 'origin'],
264 cwd=self.local) != 0:
265 raise VCSException("Git fetch failed")
266 if subprocess.call(['git', 'fetch', '--tags', 'origin'],
267 cwd=self.local) != 0:
268 raise VCSException("Git fetch failed")
269 self.refreshed = True
270 # Check out the appropriate revision...
271 rev = str(rev if rev else 'origin/master')
272 if subprocess.call(['git', 'checkout', '-f', rev], cwd=self.local) != 0:
273 raise VCSException("Git checkout failed")
274 # Get rid of any uncontrolled files left behind...
275 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
276 raise VCSException("Git clean failed")
278 def initsubmodules(self):
280 if subprocess.call(['git', 'submodule', 'init'],
281 cwd=self.local) != 0:
282 raise VCSException("Git submodule init failed")
283 if subprocess.call(['git', 'submodule', 'update'],
284 cwd=self.local) != 0:
285 raise VCSException("Git submodule update failed")
286 if subprocess.call(['git', 'submodule', 'foreach',
287 'git', 'reset', '--hard'],
288 cwd=self.local) != 0:
289 raise VCSException("Git submodule reset failed")
290 if subprocess.call(['git', 'submodule', 'foreach',
291 'git', 'clean', '-dffx'],
292 cwd=self.local) != 0:
293 raise VCSException("Git submodule clean failed")
297 p = subprocess.Popen(['git', 'tag'],
298 stdout=subprocess.PIPE, cwd=self.local)
299 return p.communicate()[0].splitlines()
302 class vcs_gitsvn(vcs):
307 # Damn git-svn tries to use a graphical password prompt, so we have to
308 # trick it into taking the password from stdin
310 if self.username is None:
312 return ('echo "%s" | DISPLAY="" ' % self.password, '--username "%s"' % self.username)
314 # If the local directory exists, but is somehow not a git repository, git
315 # will traverse up the directory tree until it finds one that is (i.e.
316 # fdroidserver) and then we'll proceed to destory it! This is called as
319 p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
320 stdout=subprocess.PIPE, cwd=self.local)
321 result = p.communicate()[0].rstrip()
322 if not result.endswith(self.local):
323 raise VCSException('Repository mismatch')
325 def gotorevisionx(self, rev):
326 if not os.path.exists(self.local):
327 # Brand new checkout...
328 gitsvn_cmd = '%sgit svn clone %s' % self.userargs()
329 if ';' in self.remote:
330 remote_split = self.remote.split(';')
331 for i in remote_split[1:]:
332 if i.startswith('trunk='):
333 gitsvn_cmd += ' -T %s' % i[6:]
334 elif i.startswith('tags='):
335 gitsvn_cmd += ' -t %s' % i[5:]
336 elif i.startswith('branches='):
337 gitsvn_cmd += ' -b %s' % i[9:]
338 if subprocess.call([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)],
340 raise VCSException("Git clone failed")
342 if subprocess.call([gitsvn_cmd + " %s %s" % (self.remote, self.local)],
344 raise VCSException("Git clone failed")
348 # Discard any working tree changes...
349 if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
350 raise VCSException("Git reset failed")
351 # Remove untracked files now, in case they're tracked in the target
352 # revision (it happens!)...
353 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
354 raise VCSException("Git clean failed")
355 if not self.refreshed:
356 # Get new commits and tags from repo...
357 if subprocess.call(['%sgit svn rebase %s' % self.userargs()],
358 cwd=self.local, shell=True) != 0:
359 raise VCSException("Git svn rebase failed")
360 self.refreshed = True
362 rev = str(rev if rev else 'master')
364 nospaces_rev = rev.replace(' ', '%20')
365 # Try finding a svn tag
366 p = subprocess.Popen(['git', 'checkout', 'tags/' + nospaces_rev],
367 cwd=self.local, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
368 out, err = p.communicate()
369 if p.returncode == 0:
372 # No tag found, normal svn rev translation
373 # Translate svn rev into git format
374 p = subprocess.Popen(['git', 'svn', 'find-rev', 'r' + rev],
375 cwd=self.local, stdout=subprocess.PIPE)
376 git_rev = p.communicate()[0].rstrip()
377 if p.returncode != 0 or not git_rev:
378 # Try a plain git checkout as a last resort
379 p = subprocess.Popen(['git', 'checkout', rev], cwd=self.local,
380 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
381 out, err = p.communicate()
382 if p.returncode == 0:
385 raise VCSException("No git treeish found and direct git checkout failed")
387 # Check out the git rev equivalent to the svn rev
388 p = subprocess.Popen(['git', 'checkout', git_rev], cwd=self.local,
389 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
390 out, err = p.communicate()
391 if p.returncode == 0:
394 raise VCSException("Git svn checkout failed")
395 # Get rid of any uncontrolled files left behind...
396 if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
397 raise VCSException("Git clean failed")
401 return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
405 p = subprocess.Popen(['git', 'svn', 'find-rev', 'HEAD'],
406 stdout=subprocess.PIPE, cwd=self.local)
407 return p.communicate()[0].strip()
415 if self.username is None:
416 return ['--non-interactive']
417 return ['--username', self.username,
418 '--password', self.password,
421 def gotorevisionx(self, rev):
422 if not os.path.exists(self.local):
423 if subprocess.call(['svn', 'checkout', self.remote, self.local] +
424 self.userargs()) != 0:
425 raise VCSException("Svn checkout failed")
429 r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
430 if subprocess.call(svncommand, cwd=self.local, shell=True) != 0:
431 raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local))
432 if not self.refreshed:
433 if subprocess.call(['svn', 'update'] +
434 self.userargs(), cwd=self.local) != 0:
435 raise VCSException("Svn update failed")
436 self.refreshed = True
438 revargs = list(['-r', rev] if rev else [])
439 if subprocess.call(['svn', 'update', '--force'] + revargs +
440 self.userargs(), cwd=self.local) != 0:
441 raise VCSException("Svn update failed")
444 p = subprocess.Popen(['svn', 'info'],
445 stdout=subprocess.PIPE, cwd=self.local)
446 for line in p.communicate()[0].splitlines():
447 if line and line.startswith('Last Changed Rev: '):
455 def gotorevisionx(self, rev):
456 if not os.path.exists(self.local):
457 if subprocess.call(['hg', 'clone', self.remote, self.local]) !=0:
458 raise VCSException("Hg clone failed")
460 if subprocess.call('hg status -uS | xargs rm -rf',
461 cwd=self.local, shell=True) != 0:
462 raise VCSException("Hg clean failed")
463 if not self.refreshed:
464 if subprocess.call(['hg', 'pull'],
465 cwd=self.local) != 0:
466 raise VCSException("Hg pull failed")
467 self.refreshed = True
469 rev = str(rev if rev else 'default')
472 if subprocess.call(['hg', 'update', '-C', rev],
473 cwd=self.local) != 0:
474 raise VCSException("Hg checkout failed")
475 p = subprocess.Popen(['hg', 'purge', '--all'], stdout=subprocess.PIPE,
477 result = p.communicate()[0]
478 if "'purge' is provided by the following extension" in result:
479 #Also delete untracked files, we have to enable purge extension for that:
480 with open(self.local+"/.hg/hgrc", "a") as myfile:
481 myfile.write("\n[extensions]\nhgext.purge=")
482 if subprocess.call(['hg', 'purge', '--all'],
483 cwd=self.local) != 0:
484 raise VCSException("HG purge failed")
486 raise VCSException("HG purge failed")
489 p = subprocess.Popen(['hg', 'tags', '-q'],
490 stdout=subprocess.PIPE, cwd=self.local)
491 return p.communicate()[0].splitlines()[1:]
499 def gotorevisionx(self, rev):
500 if not os.path.exists(self.local):
501 if subprocess.call(['bzr', 'branch', self.remote, self.local]) != 0:
502 raise VCSException("Bzr branch failed")
504 if subprocess.call(['bzr', 'clean-tree', '--force',
505 '--unknown', '--ignored'], cwd=self.local) != 0:
506 raise VCSException("Bzr revert failed")
507 if not self.refreshed:
508 if subprocess.call(['bzr', 'pull'],
509 cwd=self.local) != 0:
510 raise VCSException("Bzr update failed")
511 self.refreshed = True
513 revargs = list(['-r', rev] if rev else [])
514 if subprocess.call(['bzr', 'revert'] + revargs,
515 cwd=self.local) != 0:
516 raise VCSException("Bzr revert failed")
519 p = subprocess.Popen(['bzr', 'tags'],
520 stdout=subprocess.PIPE, cwd=self.local)
521 return [tag.split(' ')[0].strip() for tag in
522 p.communicate()[0].splitlines()]
524 def retrieve_string(xml_dir, string):
525 if string.startswith('@string/'):
526 string_search = re.compile(r'.*"'+string[8:]+'".*?>([^<]+?)<.*').search
527 for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
528 for line in file(xmlfile):
529 matches = string_search(line)
531 return retrieve_string(xml_dir, matches.group(1))
532 elif string.startswith('&') and string.endswith(';'):
533 string_search = re.compile(r'.*<!ENTITY.*'+string[1:-1]+'.*?"([^"]+?)".*>').search
534 for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
535 for line in file(xmlfile):
536 matches = string_search(line)
538 return retrieve_string(xml_dir, matches.group(1))
540 return string.replace("\\'","'")
542 # Return list of existing files that will be used to find the highest vercode
543 def manifest_paths(app_dir, flavour):
545 possible_manifests = [ os.path.join(app_dir, 'AndroidManifest.xml'),
546 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
547 os.path.join(app_dir, 'build.gradle') ]
550 possible_manifests.append(
551 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
553 return [path for path in possible_manifests if os.path.isfile(path)]
555 # Retrieve the package name
556 def fetch_real_name(app_dir, flavour):
557 app_search = re.compile(r'.*<application.*').search
558 name_search = re.compile(r'.*android:label="([^"]+)".*').search
560 for f in manifest_paths(app_dir, flavour):
561 if not f.endswith(".xml"):
563 xml_dir = os.path.join(f[:-19], 'res', 'values')
569 matches = name_search(line)
571 return retrieve_string(xml_dir, matches.group(1))
574 # Retrieve the version name
575 def version_name(original, app_dir, flavour):
576 for f in manifest_paths(app_dir, flavour):
577 if not f.endswith(".xml"):
579 xml_dir = os.path.join(f[:-19], 'res', 'values')
580 string = retrieve_string(xml_dir, original)
585 def ant_subprojects(root_dir):
587 proppath = os.path.join(root_dir, 'project.properties')
588 if not os.path.isfile(proppath):
590 with open(proppath) as f:
591 for line in f.readlines():
592 if not line.startswith('android.library.reference.'):
594 path = line.split('=')[1].strip()
595 relpath = os.path.join(root_dir, path)
596 if not os.path.isdir(relpath):
599 print "Found subproject %s..." % path
600 subprojects.append(path)
603 # Extract some information from the AndroidManifest.xml at the given path.
604 # Returns (version, vercode, package), any or all of which might be None.
605 # All values returned are strings.
606 def parse_androidmanifests(paths):
609 return (None, None, None)
611 vcsearch = re.compile(r'.*android:versionCode="([0-9]+?)".*').search
612 vnsearch = re.compile(r'.*android:versionName="([^"]+?)".*').search
613 psearch = re.compile(r'.*package="([^"]+)".*').search
615 vcsearch_g = re.compile(r'.*versionCode[ =]*([0-9]+?)[^\d].*').search
616 vnsearch_g = re.compile(r'.*versionName[ =]*"([^"]+?)".*').search
617 psearch_g = re.compile(r'.*packageName[ =]*"([^"]+)".*').search
625 gradle = path.endswith("gradle")
628 # Remember package name, may be defined separately from version+vercode
629 package = max_package
631 for line in file(path):
634 matches = psearch_g(line)
636 matches = psearch(line)
638 package = matches.group(1)
641 matches = vnsearch_g(line)
643 matches = vnsearch(line)
645 version = matches.group(1)
648 matches = vcsearch_g(line)
650 matches = vcsearch(line)
652 vercode = matches.group(1)
654 # Better some package name than nothing
655 if max_package is None:
656 max_package = package
658 if max_vercode is None or (vercode is not None and vercode > max_vercode):
659 max_version = version
660 max_vercode = vercode
661 max_package = package
663 if max_version is None:
664 max_version = "Unknown"
666 return (max_version, max_vercode, max_package)
668 class BuildException(Exception):
669 def __init__(self, value, stdout = None, stderr = None):
674 def get_wikitext(self):
675 ret = repr(self.value) + "\n"
679 ret += str(self.stdout)
684 ret += str(self.stderr)
689 ret = repr(self.value)
691 ret += "\n==== stdout begin ====\n%s\n==== stdout end ====" % self.stdout.strip()
693 ret += "\n==== stderr begin ====\n%s\n==== stderr end ====" % self.stderr.strip()
696 class VCSException(Exception):
697 def __init__(self, value):
701 return repr(self.value)
703 # Get the specified source library.
704 # Returns the path to it. Normally this is the path to be used when referencing
705 # it, which may be a subdirectory of the actual project. If you want the base
706 # directory of the project, pass 'basepath=True'.
707 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None, target=None,
708 basepath=False, raw=False, prepare=True, preponly=False):
716 name, ref = spec.split('@')
718 number, name = name.split(':', 1)
720 name, subdir = name.split('/',1)
722 srclib_path = os.path.join('srclibs', name + ".txt")
724 if not os.path.exists(srclib_path):
725 raise BuildException('srclib ' + name + ' not found.')
727 srclib = metadata.parse_srclib(srclib_path)
729 sdir = os.path.join(srclib_dir, name)
732 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
733 vcs.srclib = (name, number, sdir)
735 vcs.gotorevision(ref)
742 libdir = os.path.join(sdir, subdir)
743 elif srclib["Subdir"]:
744 for subdir in srclib["Subdir"]:
745 libdir_candidate = os.path.join(sdir, subdir)
746 if os.path.exists(libdir_candidate):
747 libdir = libdir_candidate
753 if srclib["Srclibs"]:
755 for lib in srclib["Srclibs"].split(','):
757 for t in srclibpaths:
762 raise BuildException('Missing recursive srclib %s for %s' % (
764 place_srclib(libdir, n, s_tuple[2])
769 if srclib["Prepare"]:
770 cmd = replace_config_vars(srclib["Prepare"])
772 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
773 if p.returncode != 0:
774 raise BuildException("Error running prepare command for srclib %s"
775 % name, p.stdout, p.stderr)
777 if srclib["Update Project"] == "Yes":
778 print "Updating srclib %s at path %s" % (name, libdir)
779 cmd = [os.path.join(config['sdk_path'], 'tools', 'android'),
780 'update', 'project', '-p', libdir]
782 cmd += ['-t', target]
784 # Check to see whether an error was returned without a proper exit
785 # code (this is the case for the 'no target set or target invalid'
787 if p.returncode != 0 or (p.stderr != "" and
788 p.stderr.startswith("Error: ")):
789 raise BuildException("Failed to update srclib project {0}"
790 .format(name), p.stdout, p.stderr)
792 remove_signing_keys(libdir)
797 return (name, number, libdir)
800 # Prepare the source code for a particular build
801 # 'vcs' - the appropriate vcs object for the application
802 # 'app' - the application details from the metadata
803 # 'build' - the build details from the metadata
804 # 'build_dir' - the path to the build directory, usually
806 # 'srclib_dir' - the path to the source libraries directory, usually
808 # 'extlib_dir' - the path to the external libraries directory, usually
810 # Returns the (root, srclibpaths) where:
811 # 'root' is the root directory, which may be the same as 'build_dir' or may
812 # be a subdirectory of it.
813 # 'srclibpaths' is information on the srclibs being used
814 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
816 # Optionally, the actual app source can be in a subdirectory...
817 if 'subdir' in build:
818 root_dir = os.path.join(build_dir, build['subdir'])
822 # Get a working copy of the right revision...
823 print "Getting source for revision " + build['commit']
824 vcs.gotorevision(build['commit'])
826 # Check that a subdir (if we're using one) exists. This has to happen
827 # after the checkout, since it might not exist elsewhere...
828 if not os.path.exists(root_dir):
829 raise BuildException('Missing subdir ' + root_dir)
831 # Initialise submodules if requred...
832 if build['submodules']:
834 print "Initialising submodules..."
837 # Run an init command if one is required...
839 cmd = replace_config_vars(build['init'])
841 print "Running 'init' commands in %s" % root_dir
843 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
844 if p.returncode != 0:
845 raise BuildException("Error running init command for %s:%s" %
846 (app['id'], build['version']), p.stdout, p.stderr)
848 # Generate (or update) the ant build file, build.xml...
849 updatemode = build.get('update', 'auto')
850 if (updatemode != 'no'
851 and build.get('maven', 'no') == 'no'
852 and build.get('kivy', 'no') == 'no'
853 and build.get('gradle', 'no') == 'no'):
854 parms = [os.path.join(config['sdk_path'], 'tools', 'android'),
856 if 'target' in build and build['target']:
857 parms += ['-t', build['target']]
859 if updatemode == 'auto':
860 update_dirs = ['.'] + ant_subprojects(root_dir)
862 update_dirs = [d.strip() for d in updatemode.split(';')]
863 # Force build.xml update if necessary...
864 if updatemode == 'force' or 'target' in build:
865 if updatemode == 'force':
867 buildxml = os.path.join(root_dir, 'build.xml')
868 if os.path.exists(buildxml):
869 print 'Force-removing old build.xml'
872 for d in update_dirs:
873 # Remove gen and bin dirs in libraries
876 'gen', 'bin', 'obj', # ant
877 'libs/armeabi-v7a', 'libs/armeabi', # jni
878 'libs/mips', 'libs/x86']:
879 badpath = os.path.join(root_dir, d, baddir)
880 if os.path.exists(badpath):
881 print "Removing '%s'" % badpath
882 shutil.rmtree(badpath)
883 dparms = parms + ['-p', d]
886 print "Updating main project..."
888 print "Updating subproject %s..." % d
889 p = FDroidPopen(dparms, cwd=root_dir)
890 # Check to see whether an error was returned without a proper exit
891 # code (this is the case for the 'no target set or target invalid'
893 if p.returncode != 0 or (p.stderr != "" and
894 p.stderr.startswith("Error: ")):
895 raise BuildException("Failed to update project at %s" % d,
898 # Update the local.properties file...
899 localprops = [ os.path.join(build_dir, 'local.properties') ]
900 if 'subdir' in build:
901 localprops += [ os.path.join(root_dir, 'local.properties') ]
902 for path in localprops:
903 if not os.path.isfile(path):
906 print "Updating properties file at %s" % path
911 # Fix old-fashioned 'sdk-location' by copying
912 # from sdk.dir, if necessary...
913 if build['oldsdkloc']:
914 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
916 props += "sdk-location=%s\n" % sdkloc
918 props += "sdk.dir=%s\n" % config['sdk_path']
919 props += "sdk-location=%s\n" % ['sdk_path']
920 # Add ndk location...
921 props += "ndk.dir=%s\n" % config['ndk_path']
922 props += "ndk-location=%s\n" % config['ndk_path']
923 # Add java.encoding if necessary...
924 if 'encoding' in build:
925 props += "java.encoding=%s\n" % build['encoding']
931 if build.get('gradle', 'no') != 'no':
932 flavour = build['gradle'].split('@')[0]
933 if flavour in ['main', 'yes', '']:
936 # Remove forced debuggable flags
937 print "Removing debuggable flags..."
938 for path in manifest_paths(root_dir, flavour):
939 if not os.path.isfile(path):
941 if subprocess.call(['sed','-i',
942 's/android:debuggable="[^"]*"//g', path]) != 0:
943 raise BuildException("Failed to remove debuggable flags")
945 # Insert version code and number into the manifest if necessary...
946 if build['forceversion']:
947 print "Changing the version name..."
948 for path in manifest_paths(root_dir, flavour):
949 if not os.path.isfile(path):
951 if path.endswith('.xml'):
952 if subprocess.call(['sed','-i',
953 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
955 raise BuildException("Failed to amend manifest")
956 elif path.endswith('.gradle'):
957 if subprocess.call(['sed','-i',
958 's/versionName[ ]*=[ ]*"[^"]*"/versionName = "' + build['version'] + '"/g',
960 raise BuildException("Failed to amend build.gradle")
961 if build['forcevercode']:
962 print "Changing the version code..."
963 for path in manifest_paths(root_dir, flavour):
964 if not os.path.isfile(path):
966 if path.endswith('.xml'):
967 if subprocess.call(['sed','-i',
968 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
970 raise BuildException("Failed to amend manifest")
971 elif path.endswith('.gradle'):
972 if subprocess.call(['sed','-i',
973 's/versionCode[ ]*=[ ]*[0-9]*/versionCode = ' + build['vercode'] + '/g',
975 raise BuildException("Failed to amend build.gradle")
977 # Delete unwanted files...
979 for part in build['rm'].split(';'):
980 dest = os.path.join(build_dir, part.strip())
981 rdest = os.path.abspath(dest)
983 print "Removing {0}".format(rdest)
984 if not rdest.startswith(os.path.abspath(build_dir)):
985 raise BuildException("rm for {1} is outside build root {0}".format(
986 os.path.abspath(build_dir),os.path.abspath(dest)))
987 if rdest == os.path.abspath(build_dir):
988 raise BuildException("rm removes whole build directory")
989 if os.path.lexists(rdest):
990 if os.path.islink(rdest):
991 subprocess.call('unlink ' + rdest, shell=True)
993 subprocess.call('rm -rf ' + rdest, shell=True)
996 print "...but it didn't exist"
998 # Fix apostrophes translation files if necessary...
1000 for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1001 for filename in files:
1002 if filename.endswith('.xml'):
1003 if subprocess.call(['sed','-i','s@' +
1004 r"\([^\\]\)'@\1\\'" +
1006 os.path.join(root, filename)]) != 0:
1007 raise BuildException("Failed to amend " + filename)
1009 # Fix translation files if necessary...
1010 if build['fixtrans']:
1011 for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1012 for filename in files:
1013 if filename.endswith('.xml'):
1014 f = open(os.path.join(root, filename))
1022 index = line.find("%", index)
1025 next = line[index+1:index+2]
1026 if next == "s" or next == "d":
1027 line = (line[:index+1] +
1034 # We only want to insert the positional arguments
1035 # when there is more than one argument...
1041 outlines.append(line)
1044 f = open(os.path.join(root, filename), 'w')
1045 f.writelines(outlines)
1048 remove_signing_keys(build_dir)
1050 # Add required external libraries...
1051 if 'extlibs' in build:
1052 print "Collecting prebuilt libraries..."
1053 libsdir = os.path.join(root_dir, 'libs')
1054 if not os.path.exists(libsdir):
1056 for lib in build['extlibs'].split(';'):
1059 print "...installing extlib {0}".format(lib)
1060 libf = os.path.basename(lib)
1061 libsrc = os.path.join(extlib_dir, lib)
1062 if not os.path.exists(libsrc):
1063 raise BuildException("Missing extlib file {0}".format(libsrc))
1064 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1066 # Get required source libraries...
1068 if 'srclibs' in build:
1069 target=build['target'] if 'target' in build else None
1070 print "Collecting source libraries..."
1071 for lib in build['srclibs'].split(';'):
1072 srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1073 target=target, preponly=onserver))
1075 # Apply patches if any
1076 if 'patch' in build:
1077 for patch in build['patch'].split(';'):
1078 patch = patch.strip()
1079 print "Applying " + patch
1080 patch_path = os.path.join('metadata', app['id'], patch)
1081 if subprocess.call(['patch', '-p1',
1082 '-i', os.path.abspath(patch_path)], cwd=build_dir) != 0:
1083 raise BuildException("Failed to apply patch %s" % patch_path)
1085 for name, number, libpath in srclibpaths:
1086 place_srclib(root_dir, int(number) if number else None, libpath)
1088 basesrclib = vcs.getsrclib()
1089 # If one was used for the main source, add that too.
1091 srclibpaths.append(basesrclib)
1093 # Run a pre-build command if one is required...
1094 if 'prebuild' in build:
1095 cmd = replace_config_vars(build['prebuild'])
1097 # Substitute source library paths into prebuild commands...
1098 for name, number, libpath in srclibpaths:
1099 libpath = os.path.relpath(libpath, root_dir)
1100 cmd = cmd.replace('$$' + name + '$$', libpath)
1103 print "Running 'prebuild' commands in %s" % root_dir
1105 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1106 if p.returncode != 0:
1107 raise BuildException("Error running prebuild command for %s:%s" %
1108 (app['id'], build['version']), p.stdout, p.stderr)
1110 return (root_dir, srclibpaths)
1113 # Scan the source code in the given directory (and all subdirectories)
1114 # and return a list of potential problems.
1115 def scan_source(build_dir, root_dir, thisbuild):
1119 # Common known non-free blobs (always lower case):
1120 usual_suspects = ['flurryagent',
1122 'libgoogleanalytics',
1123 'admob-sdk-android',
1125 'googleadmobadssdk',
1126 'google-play-services',
1130 'youtubeandroidplayerapi',
1135 def getpaths(field):
1137 if field not in thisbuild:
1139 for p in thisbuild[field].split(';'):
1143 elif p.startswith('./'):
1145 elif not p.startswith('/'):
1151 scanignore = getpaths('scanignore')
1152 scandelete = getpaths('scandelete')
1154 ms = magic.open(magic.MIME_TYPE)
1158 for i in scanignore:
1159 if fd.startswith(i):
1164 for i in scandelete:
1165 if fd.startswith(i):
1169 def removeproblem(what, fd, fp):
1170 print 'Removing %s at %s' % (what, fd)
1173 def handleproblem(what, fd, fp):
1175 removeproblem(what, fd, fp)
1177 problems.append('Found %s at %s' % (what, fd))
1179 # Iterate through all files in the source code...
1180 for r,d,f in os.walk(build_dir):
1183 if '/.hg' in r or '/.git' in r or '/.svn' in r:
1186 # Path (relative) to the file...
1187 fp = os.path.join(r, curfile)
1188 fd = fp[len(build_dir):]
1190 # Check if this file has been explicitly excluded from scanning...
1194 for suspect in usual_suspects:
1195 if suspect in curfile.lower():
1196 handleproblem('usual supect', fd, fp)
1199 if mime == 'application/x-sharedlib':
1200 handleproblem('shared library', fd, fp)
1201 elif mime == 'application/x-archive':
1202 handleproblem('static library', fd, fp)
1203 elif mime == 'application/x-executable':
1204 handleproblem('binary executable', fd, fp)
1205 elif mime == 'application/jar' and fp.endswith('.apk'):
1206 removeproblem('APK file', fd, fp)
1208 elif curfile.endswith('.java'):
1209 for line in file(fp):
1210 if 'DexClassLoader' in line:
1211 handleproblem('DexClassLoader', fd, fp)
1215 # Presence of a jni directory without buildjni=yes might
1216 # indicate a problem... (if it's not a problem, explicitly use
1217 # buildjni=no to bypass this check)
1218 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1219 thisbuild.get('buildjni') is None):
1220 msg = 'Found jni directory, but buildjni is not enabled'
1221 problems.append(msg)
1229 self.path = os.path.join('stats', 'known_apks.txt')
1231 if os.path.exists(self.path):
1232 for line in file( self.path):
1233 t = line.rstrip().split(' ')
1235 self.apks[t[0]] = (t[1], None)
1237 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1238 self.changed = False
1240 def writeifchanged(self):
1242 if not os.path.exists('stats'):
1244 f = open(self.path, 'w')
1246 for apk, app in self.apks.iteritems():
1248 line = apk + ' ' + appid
1250 line += ' ' + time.strftime('%Y-%m-%d', added)
1252 for line in sorted(lst):
1253 f.write(line + '\n')
1256 # Record an apk (if it's new, otherwise does nothing)
1257 # Returns the date it was added.
1258 def recordapk(self, apk, app):
1259 if not apk in self.apks:
1260 self.apks[apk] = (app, time.gmtime(time.time()))
1262 _, added = self.apks[apk]
1265 # Look up information - given the 'apkname', returns (app id, date added/None).
1266 # Or returns None for an unknown apk.
1267 def getapp(self, apkname):
1268 if apkname in self.apks:
1269 return self.apks[apkname]
1272 # Get the most recent 'num' apps added to the repo, as a list of package ids
1273 # with the most recent first.
1274 def getlatest(self, num):
1276 for apk, app in self.apks.iteritems():
1280 if apps[appid] > added:
1284 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1285 lst = [app for app,added in sortedapps]
1289 def isApkDebuggable(apkfile, config):
1290 """Returns True if the given apk file is debuggable
1292 :param apkfile: full path to the apk to check"""
1294 p = subprocess.Popen([os.path.join(config['sdk_path'],
1295 'build-tools', config['build_tools'], 'aapt'),
1296 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1297 stdout=subprocess.PIPE)
1298 output = p.communicate()[0]
1299 if p.returncode != 0:
1300 print "ERROR: Failed to get apk manifest information"
1302 for line in output.splitlines():
1303 if line.find('android:debuggable') != -1 and not line.endswith('0x0'):
1308 class AsynchronousFileReader(threading.Thread):
1310 Helper class to implement asynchronous reading of a file
1311 in a separate thread. Pushes read lines on a queue to
1312 be consumed in another thread.
1315 def __init__(self, fd, queue):
1316 assert isinstance(queue, Queue.Queue)
1317 assert callable(fd.readline)
1318 threading.Thread.__init__(self)
1323 '''The body of the tread: read lines and put them on the queue.'''
1324 for line in iter(self._fd.readline, ''):
1325 self._queue.put(line)
1328 '''Check whether there is no more content to expect.'''
1329 return not self.is_alive() and self._queue.empty()
1337 def FDroidPopen(commands, cwd=None):
1339 Runs a command the FDroid way and returns return code and output
1341 :param commands, cwd: like subprocess.Popen
1346 print "Directory: %s" % cwd
1347 print " > %s" % ' '.join(commands)
1349 result = PopenResult()
1350 p = subprocess.Popen(commands, cwd=cwd,
1351 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1353 stdout_queue = Queue.Queue()
1354 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1355 stdout_reader.start()
1356 stderr_queue = Queue.Queue()
1357 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1358 stderr_reader.start()
1360 # Check the queues for output (until there is no more to get)
1361 while not stdout_reader.eof() or not stderr_reader.eof():
1362 # Show what we received from standard output
1363 while not stdout_queue.empty():
1364 line = stdout_queue.get()
1366 # Output directly to console
1367 sys.stdout.write(line)
1369 result.stdout += line
1371 # Show what we received from standard error
1372 while not stderr_queue.empty():
1373 line = stderr_queue.get()
1375 # Output directly to console
1376 sys.stderr.write(line)
1378 result.stderr += line
1382 result.returncode = p.returncode
1385 def remove_signing_keys(build_dir):
1386 for root, dirs, files in os.walk(build_dir):
1387 if 'build.gradle' in files:
1388 path = os.path.join(root, 'build.gradle')
1391 with open(path, "r") as o:
1392 lines = o.readlines()
1395 with open(path, "w") as o:
1397 if 'signingConfigs ' in line:
1405 elif any(s in line for s in (
1407 'android.signingConfigs.',
1408 'variant.outputFile = ',
1414 if changed and options.verbose:
1415 print "Cleaned build.gradle of keysigning configs at %s" % path
1417 for propfile in ('build.properties', 'default.properties', 'ant.properties'):
1418 if propfile in files:
1419 path = os.path.join(root, propfile)
1422 with open(path, "r") as o:
1423 lines = o.readlines()
1425 with open(path, "w") as o:
1427 if line.startswith('key.store'):
1432 if changed and options.verbose:
1433 print "Cleaned %s of keysigning configs at %s" % (propfile,path)
1435 def replace_config_vars(cmd):
1436 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1437 cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1438 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1441 def place_srclib(root_dir, number, libpath):
1444 relpath = os.path.relpath(libpath, root_dir)
1445 proppath = os.path.join(root_dir, 'project.properties')
1447 with open(proppath, "r") as o:
1448 lines = o.readlines()
1450 with open(proppath, "w") as o:
1453 if line.startswith('android.library.reference.%d=' % number):
1454 o.write('android.library.reference.%d=%s\n' % (number,relpath))
1459 o.write('android.library.reference.%d=%s\n' % (number,relpath))