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-2014 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
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 logging.critical("Missing config file - is this a repo directory?")
54 logging.info("Reading %s" % config_file)
55 execfile(config_file, config)
58 'sdk_path': "$ANDROID_HOME",
59 'ndk_path': "$ANDROID_NDK",
60 'build_tools': "19.0.1",
65 'update_stats': False,
66 'stats_to_carbon': False,
68 'build_server_always': False,
74 for k, v in defconfig.items():
78 # Expand environment variables
79 for k, v in config.items():
82 v = os.path.expanduser(v)
83 config[k] = os.path.expandvars(v)
85 if not config['sdk_path']:
86 logging.critical("$ANDROID_HOME is not set!")
88 if not os.path.isdir(config['sdk_path']):
89 logging.critical("$ANDROID_HOME points to a non-existing directory!")
92 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
93 st = os.stat(config_file)
94 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
95 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
99 # Given the arguments in the form of multiple appid:[vc] strings, this returns
100 # a dictionary with the set of vercodes specified for each package.
101 def read_pkg_args(args, allow_vercodes=False):
108 if allow_vercodes and ':' in p:
109 package, vercode = p.split(':')
111 package, vercode = p, None
112 if package not in vercodes:
113 vercodes[package] = [vercode] if vercode else []
115 elif vercode and vercode not in vercodes[package]:
116 vercodes[package] += [vercode] if vercode else []
120 # On top of what read_pkg_args does, this returns the whole app metadata, but
121 # limiting the builds list to the builds matching the vercodes specified.
122 def read_app_args(args, allapps, allow_vercodes=False):
124 vercodes = read_pkg_args(args, allow_vercodes)
129 apps = [app for app in allapps if app['id'] in vercodes]
131 if len(apps) != len(vercodes):
132 allids = [app["id"] for app in allapps]
135 logging.critical("No such package: %s" % p)
136 raise Exception("Found invalid app ids in arguments")
138 raise Exception("No packages specified")
142 vc = vercodes[app['id']]
145 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
146 if len(app['builds']) != len(vercodes[app['id']]):
148 allvcs = [b['vercode'] for b in app['builds']]
149 for v in vercodes[app['id']]:
151 logging.critical("No such vercode %s for app %s" % (v, app['id']))
154 raise Exception("Found invalid vercodes for some apps")
158 def has_extension(filename, extension):
159 name, ext = os.path.splitext(filename)
160 ext = ext.lower()[1:]
161 return ext == extension
165 def apknameinfo(filename):
167 filename = os.path.basename(filename)
168 if apk_regex is None:
169 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
170 m = apk_regex.match(filename)
172 result = (m.group(1), m.group(2))
173 except AttributeError:
174 raise Exception("Invalid apk name: %s" % filename)
177 def getapkname(app, build):
178 return "%s_%s.apk" % (app['id'], build['vercode'])
180 def getsrcname(app, build):
181 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
187 return app['Auto Name']
191 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
193 def getvcs(vcstype, remote, local):
195 return vcs_git(remote, local)
197 return vcs_svn(remote, local)
198 if vcstype == 'git-svn':
199 return vcs_gitsvn(remote, local)
201 return vcs_hg(remote, local)
203 return vcs_bzr(remote, local)
204 if vcstype == 'srclib':
205 if local != 'build/srclib/' + remote:
206 raise VCSException("Error: srclib paths are hard-coded!")
207 return getsrclib(remote, 'build/srclib', raw=True)
208 raise VCSException("Invalid vcs type " + vcstype)
210 def getsrclibvcs(name):
211 srclib_path = os.path.join('srclibs', name + ".txt")
212 if not os.path.exists(srclib_path):
213 raise VCSException("Missing srclib " + name)
214 return metadata.parse_srclib(srclib_path)['Repo Type']
217 def __init__(self, remote, local):
219 # svn, git-svn and bzr may require auth
221 if self.repotype() in ('svn', 'git-svn', 'bzr'):
223 self.username, remote = remote.split('@')
224 if ':' not in self.username:
225 raise VCSException("Password required with username")
226 self.username, self.password = self.username.split(':')
230 self.refreshed = False
233 # Take the local repository to a clean version of the given revision, which
234 # is specificed in the VCS's native format. Beforehand, the repository can
235 # be dirty, or even non-existent. If the repository does already exist
236 # locally, it will be updated from the origin, but only once in the
237 # lifetime of the vcs object.
238 # None is acceptable for 'rev' if you know you are cloning a clean copy of
239 # the repo - otherwise it must specify a valid revision.
240 def gotorevision(self, rev):
242 # The .fdroidvcs-id file for a repo tells us what VCS type
243 # and remote that directory was created from, allowing us to drop it
244 # automatically if either of those things changes.
245 fdpath = os.path.join(self.local, '..',
246 '.fdroidvcs-' + os.path.basename(self.local))
247 cdata = self.repotype() + ' ' + self.remote
250 if os.path.exists(self.local):
251 if os.path.exists(fdpath):
252 with open(fdpath, 'r') as f:
258 logging.info("Repository details changed - deleting")
261 logging.info("Repository details missing - deleting")
263 shutil.rmtree(self.local)
265 self.gotorevisionx(rev)
267 # If necessary, write the .fdroidvcs file.
269 with open(fdpath, 'w') as f:
272 # Derived classes need to implement this. It's called once basic checking
273 # has been performend.
274 def gotorevisionx(self, rev):
275 raise VCSException("This VCS type doesn't define gotorevisionx")
277 # Initialise and update submodules
278 def initsubmodules(self):
279 raise VCSException('Submodules not supported for this vcs type')
281 # Get a list of all known tags
283 raise VCSException('gettags not supported for this vcs type')
285 # Get current commit reference (hash, revision, etc)
287 raise VCSException('getref not supported for this vcs type')
289 # Returns the srclib (name, path) used in setting up the current
299 # If the local directory exists, but is somehow not a git repository, git
300 # will traverse up the directory tree until it finds one that is (i.e.
301 # fdroidserver) and then we'll proceed to destroy it! This is called as
304 p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
305 result = p.stdout.rstrip()
306 if not result.endswith(self.local):
307 raise VCSException('Repository mismatch')
309 def gotorevisionx(self, rev):
310 if not os.path.exists(self.local):
312 p = SilentPopen(['git', 'clone', self.remote, self.local])
313 if p.returncode != 0:
314 raise VCSException("Git clone failed")
318 # Discard any working tree changes
319 p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
320 if p.returncode != 0:
321 raise VCSException("Git reset failed")
322 # Remove untracked files now, in case they're tracked in the target
323 # revision (it happens!)
324 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
325 if p.returncode != 0:
326 raise VCSException("Git clean failed")
327 if not self.refreshed:
328 # Get latest commits and tags from remote
329 p = SilentPopen(['git', 'fetch', 'origin'], cwd=self.local)
330 if p.returncode != 0:
331 raise VCSException("Git fetch failed")
332 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
333 if p.returncode != 0:
334 raise VCSException("Git fetch failed")
335 self.refreshed = True
336 # Check out the appropriate revision
337 rev = str(rev if rev else 'origin/master')
338 p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
339 if p.returncode != 0:
340 raise VCSException("Git checkout failed")
341 # Get rid of any uncontrolled files left behind
342 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
343 if p.returncode != 0:
344 raise VCSException("Git clean failed")
346 def initsubmodules(self):
349 ['git', 'reset', '--hard'],
350 ['git', 'clean', '-dffx'],
352 p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
353 if p.returncode != 0:
354 raise VCSException("Git submodule reset failed")
355 p = SilentPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
356 if p.returncode != 0:
357 raise VCSException("Git submodule update failed")
361 p = SilentPopen(['git', 'tag'], cwd=self.local)
362 return p.stdout.splitlines()
365 class vcs_gitsvn(vcs):
370 # Damn git-svn tries to use a graphical password prompt, so we have to
371 # trick it into taking the password from stdin
373 if self.username is None:
375 return ('echo "%s" | DISPLAY="" ' % self.password, '--username "%s"' % self.username)
377 # If the local directory exists, but is somehow not a git repository, git
378 # will traverse up the directory tree until it finds one that is (i.e.
379 # fdroidserver) and then we'll proceed to destory it! This is called as
382 p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
383 result = p.stdout.rstrip()
384 if not result.endswith(self.local):
385 raise VCSException('Repository mismatch')
387 def gotorevisionx(self, rev):
388 if not os.path.exists(self.local):
390 gitsvn_cmd = '%sgit svn clone %s' % self.userargs()
391 if ';' in self.remote:
392 remote_split = self.remote.split(';')
393 for i in remote_split[1:]:
394 if i.startswith('trunk='):
395 gitsvn_cmd += ' -T %s' % i[6:]
396 elif i.startswith('tags='):
397 gitsvn_cmd += ' -t %s' % i[5:]
398 elif i.startswith('branches='):
399 gitsvn_cmd += ' -b %s' % i[9:]
400 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
401 if p.returncode != 0:
402 raise VCSException("Git clone failed")
404 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
405 if p.returncode != 0:
406 raise VCSException("Git clone failed")
410 # Discard any working tree changes
411 p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
412 if p.returncode != 0:
413 raise VCSException("Git reset failed")
414 # Remove untracked files now, in case they're tracked in the target
415 # revision (it happens!)
416 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
417 if p.returncode != 0:
418 raise VCSException("Git clean failed")
419 if not self.refreshed:
420 # Get new commits, branches and tags from repo
421 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
422 if p.returncode != 0:
423 raise VCSException("Git svn fetch failed")
424 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
425 if p.returncode != 0:
426 raise VCSException("Git svn rebase failed")
427 self.refreshed = True
429 rev = str(rev if rev else 'master')
431 nospaces_rev = rev.replace(' ', '%20')
432 # Try finding a svn tag
433 p = SilentPopen(['git', 'checkout', 'tags/' + nospaces_rev], cwd=self.local)
434 if p.returncode != 0:
435 # No tag found, normal svn rev translation
436 # Translate svn rev into git format
437 rev_split = rev.split('/')
438 if len(rev_split) > 1:
439 treeish = rev_split[0]
440 svn_rev = rev_split[1]
443 # if no branch is specified, then assume trunk (ie. 'master'
448 p = SilentPopen(['git', 'svn', 'find-rev', 'r' + svn_rev, treeish], cwd=self.local)
449 git_rev = p.stdout.rstrip()
451 if p.returncode != 0 or not git_rev:
452 # Try a plain git checkout as a last resort
453 p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
454 if p.returncode != 0:
455 raise VCSException("No git treeish found and direct git checkout failed")
457 # Check out the git rev equivalent to the svn rev
458 p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
459 if p.returncode != 0:
460 raise VCSException("Git svn checkout failed")
462 # Get rid of any uncontrolled files left behind
463 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
464 if p.returncode != 0:
465 raise VCSException("Git clean failed")
469 return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
473 p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
474 if p.returncode != 0:
476 return p.stdout.strip()
484 if self.username is None:
485 return ['--non-interactive']
486 return ['--username', self.username,
487 '--password', self.password,
490 def gotorevisionx(self, rev):
491 if not os.path.exists(self.local):
492 p = SilentPopen(['svn', 'checkout', self.remote, self.local] + self.userargs())
493 if p.returncode != 0:
494 raise VCSException("Svn checkout failed")
498 r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
499 p = SilentPopen([svncommand], cwd=self.local, shell=True)
500 if p.returncode != 0:
501 raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local))
502 if not self.refreshed:
503 p = SilentPopen(['svn', 'update'] + self.userargs(), cwd=self.local)
504 if p.returncode != 0:
505 raise VCSException("Svn update failed")
506 self.refreshed = True
508 revargs = list(['-r', rev] if rev else [])
509 p = SilentPopen(['svn', 'update', '--force'] + revargs + self.userargs(), cwd=self.local)
510 if p.returncode != 0:
511 raise VCSException("Svn update failed")
514 p = SilentPopen(['svn', 'info'], cwd=self.local)
515 for line in p.stdout.splitlines():
516 if line and line.startswith('Last Changed Rev: '):
525 def gotorevisionx(self, rev):
526 if not os.path.exists(self.local):
527 p = SilentPopen(['hg', 'clone', self.remote, self.local])
528 if p.returncode != 0:
529 raise VCSException("Hg clone failed")
531 p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
532 if p.returncode != 0:
533 raise VCSException("Hg clean failed")
534 if not self.refreshed:
535 p = SilentPopen(['hg', 'pull'], cwd=self.local)
536 if p.returncode != 0:
537 raise VCSException("Hg pull failed")
538 self.refreshed = True
540 rev = str(rev if rev else 'default')
543 p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
544 if p.returncode != 0:
545 raise VCSException("Hg checkout failed")
546 p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
547 # Also delete untracked files, we have to enable purge extension for that:
548 if "'purge' is provided by the following extension" in p.stdout:
549 with open(self.local+"/.hg/hgrc", "a") as myfile:
550 myfile.write("\n[extensions]\nhgext.purge=\n")
551 p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
552 if p.returncode != 0:
553 raise VCSException("HG purge failed")
554 elif p.returncode != 0:
555 raise VCSException("HG purge failed")
558 p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
559 return p.stdout.splitlines()[1:]
567 def gotorevisionx(self, rev):
568 if not os.path.exists(self.local):
569 p = SilentPopen(['bzr', 'branch', self.remote, self.local])
570 if p.returncode != 0:
571 raise VCSException("Bzr branch failed")
573 p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
574 if p.returncode != 0:
575 raise VCSException("Bzr revert failed")
576 if not self.refreshed:
577 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
578 if p.returncode != 0:
579 raise VCSException("Bzr update failed")
580 self.refreshed = True
582 revargs = list(['-r', rev] if rev else [])
583 p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
584 if p.returncode != 0:
585 raise VCSException("Bzr revert failed")
588 p = SilentPopen(['bzr', 'tags'], cwd=self.local)
589 return [tag.split(' ')[0].strip() for tag in
590 p.stdout.splitlines()]
592 def retrieve_string(xml_dir, string):
593 if string.startswith('@string/'):
594 string_search = re.compile(r'.*"'+string[8:]+'".*?>([^<]+?)<.*').search
595 for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
596 for line in file(xmlfile):
597 matches = string_search(line)
599 return retrieve_string(xml_dir, matches.group(1))
600 elif string.startswith('&') and string.endswith(';'):
601 string_search = re.compile(r'.*<!ENTITY.*'+string[1:-1]+'.*?"([^"]+?)".*>').search
602 for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
603 for line in file(xmlfile):
604 matches = string_search(line)
606 return retrieve_string(xml_dir, matches.group(1))
608 return string.replace("\\'","'")
610 # Return list of existing files that will be used to find the highest vercode
611 def manifest_paths(app_dir, flavour):
613 possible_manifests = [ os.path.join(app_dir, 'AndroidManifest.xml'),
614 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
615 os.path.join(app_dir, 'build.gradle') ]
618 possible_manifests.append(
619 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
621 return [path for path in possible_manifests if os.path.isfile(path)]
623 # Retrieve the package name
624 def fetch_real_name(app_dir, flavour):
625 app_search = re.compile(r'.*<application.*').search
626 name_search = re.compile(r'.*android:label="([^"]+)".*').search
628 for f in manifest_paths(app_dir, flavour):
629 if not has_extension(f, 'xml'):
631 xml_dir = os.path.join(f[:-19], 'res', 'values')
637 matches = name_search(line)
639 return retrieve_string(xml_dir, matches.group(1)).strip()
642 # Retrieve the version name
643 def version_name(original, app_dir, flavour):
644 for f in manifest_paths(app_dir, flavour):
645 if not has_extension(f, 'xml'):
647 xml_dir = os.path.join(f[:-19], 'res', 'values')
648 string = retrieve_string(xml_dir, original)
653 def get_library_references(root_dir):
655 proppath = os.path.join(root_dir, 'project.properties')
656 if not os.path.isfile(proppath):
658 with open(proppath) as f:
659 for line in f.readlines():
660 if not line.startswith('android.library.reference.'):
662 path = line.split('=')[1].strip()
663 relpath = os.path.join(root_dir, path)
664 if not os.path.isdir(relpath):
666 logging.info("Found subproject at %s" % path)
667 libraries.append(path)
670 def ant_subprojects(root_dir):
671 subprojects = get_library_references(root_dir)
672 for subpath in subprojects:
673 subrelpath = os.path.join(root_dir, subpath)
674 for p in get_library_references(subrelpath):
675 relp = os.path.normpath(os.path.join(subpath,p))
676 if relp not in subprojects:
677 subprojects.insert(0, relp)
680 def remove_debuggable_flags(root_dir):
681 # Remove forced debuggable flags
682 logging.info("Removing debuggable flags")
683 for root, dirs, files in os.walk(root_dir):
684 if 'AndroidManifest.xml' in files:
685 path = os.path.join(root, 'AndroidManifest.xml')
686 p = FDroidPopen(['sed','-i', 's/android:debuggable="[^"]*"//g', path])
687 if p.returncode != 0:
688 raise BuildException("Failed to remove debuggable flags of %s" % path)
690 # Extract some information from the AndroidManifest.xml at the given path.
691 # Returns (version, vercode, package), any or all of which might be None.
692 # All values returned are strings.
693 def parse_androidmanifests(paths):
696 return (None, None, None)
698 vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
699 vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
700 psearch = re.compile(r'.*package="([^"]+)".*').search
702 vcsearch_g = re.compile(r'.*versionCode[ ]*[=]*[ ]*["\']*([0-9]+)["\']*').search
703 vnsearch_g = re.compile(r'.*versionName[ ]*[=]*[ ]*(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
704 psearch_g = re.compile(r'.*packageName[ ]*[=]*[ ]*["\']([^"]+)["\'].*').search
712 gradle = has_extension(path, 'gradle')
715 # Remember package name, may be defined separately from version+vercode
716 package = max_package
718 for line in file(path):
721 matches = psearch_g(line)
723 matches = psearch(line)
725 package = matches.group(1)
728 matches = vnsearch_g(line)
730 matches = vnsearch(line)
732 version = matches.group(2 if gradle else 1)
735 matches = vcsearch_g(line)
737 matches = vcsearch(line)
739 vercode = matches.group(1)
741 # Better some package name than nothing
742 if max_package is None:
743 max_package = package
745 if max_vercode is None or (vercode is not None and vercode > max_vercode):
746 max_version = version
747 max_vercode = vercode
748 max_package = package
750 if max_version is None:
751 max_version = "Unknown"
753 return (max_version, max_vercode, max_package)
755 class BuildException(Exception):
756 def __init__(self, value, detail = None):
760 def get_wikitext(self):
761 ret = repr(self.value) + "\n"
765 ret += str(self.detail)
770 ret = repr(self.value)
772 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
775 class VCSException(Exception):
776 def __init__(self, value):
780 return repr(self.value)
782 # Get the specified source library.
783 # Returns the path to it. Normally this is the path to be used when referencing
784 # it, which may be a subdirectory of the actual project. If you want the base
785 # directory of the project, pass 'basepath=True'.
786 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
787 basepath=False, raw=False, prepare=True, preponly=False):
795 name, ref = spec.split('@')
797 number, name = name.split(':', 1)
799 name, subdir = name.split('/',1)
801 srclib_path = os.path.join('srclibs', name + ".txt")
803 if not os.path.exists(srclib_path):
804 raise BuildException('srclib ' + name + ' not found.')
806 srclib = metadata.parse_srclib(srclib_path)
808 sdir = os.path.join(srclib_dir, name)
811 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
812 vcs.srclib = (name, number, sdir)
814 vcs.gotorevision(ref)
821 libdir = os.path.join(sdir, subdir)
822 elif srclib["Subdir"]:
823 for subdir in srclib["Subdir"]:
824 libdir_candidate = os.path.join(sdir, subdir)
825 if os.path.exists(libdir_candidate):
826 libdir = libdir_candidate
832 if srclib["Srclibs"]:
833 for n,lib in enumerate(srclib["Srclibs"]):
835 for t in srclibpaths:
840 raise BuildException('Missing recursive srclib %s for %s' % (
842 place_srclib(libdir, n, s_tuple[2])
844 remove_signing_keys(sdir)
845 remove_debuggable_flags(sdir)
849 if srclib["Prepare"]:
850 cmd = replace_config_vars(srclib["Prepare"])
852 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
853 if p.returncode != 0:
854 raise BuildException("Error running prepare command for srclib %s"
860 return (name, number, libdir)
863 # Prepare the source code for a particular build
864 # 'vcs' - the appropriate vcs object for the application
865 # 'app' - the application details from the metadata
866 # 'build' - the build details from the metadata
867 # 'build_dir' - the path to the build directory, usually
869 # 'srclib_dir' - the path to the source libraries directory, usually
871 # 'extlib_dir' - the path to the external libraries directory, usually
873 # Returns the (root, srclibpaths) where:
874 # 'root' is the root directory, which may be the same as 'build_dir' or may
875 # be a subdirectory of it.
876 # 'srclibpaths' is information on the srclibs being used
877 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
879 # Optionally, the actual app source can be in a subdirectory
880 if 'subdir' in build:
881 root_dir = os.path.join(build_dir, build['subdir'])
885 # Get a working copy of the right revision
886 logging.info("Getting source for revision " + build['commit'])
887 vcs.gotorevision(build['commit'])
889 # Initialise submodules if requred
890 if build['submodules']:
891 logging.info("Initialising submodules")
894 # Check that a subdir (if we're using one) exists. This has to happen
895 # after the checkout, since it might not exist elsewhere
896 if not os.path.exists(root_dir):
897 raise BuildException('Missing subdir ' + root_dir)
899 # Run an init command if one is required
901 cmd = replace_config_vars(build['init'])
902 logging.info("Running 'init' commands in %s" % root_dir)
904 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
905 if p.returncode != 0:
906 raise BuildException("Error running init command for %s:%s" %
907 (app['id'], build['version']), p.stdout)
909 # Apply patches if any
911 for patch in build['patch']:
912 patch = patch.strip()
913 logging.info("Applying " + patch)
914 patch_path = os.path.join('metadata', app['id'], patch)
915 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
916 if p.returncode != 0:
917 raise BuildException("Failed to apply patch %s" % patch_path)
919 # Get required source libraries
921 if 'srclibs' in build:
922 logging.info("Collecting source libraries")
923 for lib in build['srclibs']:
924 srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
927 for name, number, libpath in srclibpaths:
928 place_srclib(root_dir, int(number) if number else None, libpath)
930 basesrclib = vcs.getsrclib()
931 # If one was used for the main source, add that too.
933 srclibpaths.append(basesrclib)
935 # Update the local.properties file
936 localprops = [ os.path.join(build_dir, 'local.properties') ]
937 if 'subdir' in build:
938 localprops += [ os.path.join(root_dir, 'local.properties') ]
939 for path in localprops:
940 if not os.path.isfile(path):
942 logging.info("Updating properties file at %s" % path)
947 # Fix old-fashioned 'sdk-location' by copying
948 # from sdk.dir, if necessary
949 if build['oldsdkloc']:
950 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
952 props += "sdk-location=%s\n" % sdkloc
954 props += "sdk.dir=%s\n" % config['sdk_path']
955 props += "sdk-location=%s\n" % config['sdk_path']
956 if 'ndk_path' in config:
958 props += "ndk.dir=%s\n" % config['ndk_path']
959 props += "ndk-location=%s\n" % config['ndk_path']
960 # Add java.encoding if necessary
961 if 'encoding' in build:
962 props += "java.encoding=%s\n" % build['encoding']
968 if build['type'] == 'gradle':
969 flavour = build['gradle'].split('@')[0]
970 if flavour in ['main', 'yes', '']:
973 if 'target' in build:
974 n = build["target"].split('-')[1]
975 FDroidPopen(['sed', '-i',
976 's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+n+'@g',
977 'build.gradle'], cwd=root_dir)
978 if '@' in build['gradle']:
979 gradle_dir = os.path.join(root_dir, build['gradle'].split('@',1)[1])
980 gradle_dir = os.path.normpath(gradle_dir)
981 FDroidPopen(['sed', '-i',
982 's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+n+'@g',
983 'build.gradle'], cwd=gradle_dir)
985 # Remove forced debuggable flags
986 remove_debuggable_flags(root_dir)
988 # Insert version code and number into the manifest if necessary
989 if build['forceversion']:
990 logging.info("Changing the version name")
991 for path in manifest_paths(root_dir, flavour):
992 if not os.path.isfile(path):
994 if has_extension(path, 'xml'):
995 p = SilentPopen(['sed', '-i',
996 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
998 if p.returncode != 0:
999 raise BuildException("Failed to amend manifest")
1000 elif has_extension(path, 'gradle'):
1001 p = SilentPopen(['sed', '-i',
1002 's/versionName[ ]*=[ ]*"[^"]*"/versionName = "' + build['version'] + '"/g',
1004 if p.returncode != 0:
1005 raise BuildException("Failed to amend build.gradle")
1006 if build['forcevercode']:
1007 logging.info("Changing the version code")
1008 for path in manifest_paths(root_dir, flavour):
1009 if not os.path.isfile(path):
1011 if has_extension(path, 'xml'):
1012 p = SilentPopen(['sed', '-i',
1013 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1015 if p.returncode != 0:
1016 raise BuildException("Failed to amend manifest")
1017 elif has_extension(path, 'gradle'):
1018 p = SilentPopen(['sed', '-i',
1019 's/versionCode[ ]*=[ ]*[0-9]*/versionCode = ' + build['vercode'] + '/g',
1021 if p.returncode != 0:
1022 raise BuildException("Failed to amend build.gradle")
1024 # Delete unwanted files
1026 for part in build['rm']:
1027 dest = os.path.join(build_dir, part)
1028 logging.info("Removing {0}".format(part))
1029 if os.path.lexists(dest):
1030 if os.path.islink(dest):
1031 SilentPopen(['unlink ' + dest], shell=True)
1033 SilentPopen(['rm -rf ' + dest], shell=True)
1035 logging.info("...but it didn't exist")
1037 remove_signing_keys(build_dir)
1039 # Add required external libraries
1040 if 'extlibs' in build:
1041 logging.info("Collecting prebuilt libraries")
1042 libsdir = os.path.join(root_dir, 'libs')
1043 if not os.path.exists(libsdir):
1045 for lib in build['extlibs']:
1047 logging.info("...installing extlib {0}".format(lib))
1048 libf = os.path.basename(lib)
1049 libsrc = os.path.join(extlib_dir, lib)
1050 if not os.path.exists(libsrc):
1051 raise BuildException("Missing extlib file {0}".format(libsrc))
1052 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1054 # Run a pre-build command if one is required
1055 if 'prebuild' in build:
1056 cmd = replace_config_vars(build['prebuild'])
1058 # Substitute source library paths into prebuild commands
1059 for name, number, libpath in srclibpaths:
1060 libpath = os.path.relpath(libpath, root_dir)
1061 cmd = cmd.replace('$$' + name + '$$', libpath)
1063 logging.info("Running 'prebuild' commands in %s" % root_dir)
1065 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1066 if p.returncode != 0:
1067 raise BuildException("Error running prebuild command for %s:%s" %
1068 (app['id'], build['version']), p.stdout)
1070 updatemode = build.get('update', ['auto'])
1071 # Generate (or update) the ant build file, build.xml...
1072 if updatemode != ['no'] and build['type'] == 'ant':
1073 parms = [os.path.join(config['sdk_path'], 'tools', 'android'), 'update']
1074 lparms = parms + ['lib-project']
1075 parms = parms + ['project']
1077 if 'target' in build and build['target']:
1078 parms += ['-t', build['target']]
1079 lparms += ['-t', build['target']]
1080 if updatemode == ['auto']:
1081 update_dirs = ant_subprojects(root_dir) + ['.']
1083 update_dirs = updatemode
1085 for d in update_dirs:
1086 subdir = os.path.join(root_dir, d)
1088 print("Updating main project")
1089 cmd = parms + ['-p', d]
1091 print("Updating subproject %s" % d)
1092 cmd = lparms + ['-p', d]
1093 p = FDroidPopen(cmd, cwd=root_dir)
1094 # Check to see whether an error was returned without a proper exit
1095 # code (this is the case for the 'no target set or target invalid'
1097 if p.returncode != 0 or p.stdout.startswith("Error: "):
1098 raise BuildException("Failed to update project at %s" % d, p.stdout)
1099 # Clean update dirs via ant
1101 logging.info("Cleaning subproject %s" % d)
1102 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1104 return (root_dir, srclibpaths)
1106 # Scan the source code in the given directory (and all subdirectories)
1107 # and return a list of potential problems.
1108 def scan_source(build_dir, root_dir, thisbuild):
1112 # Common known non-free blobs (always lower case):
1113 usual_suspects = ['flurryagent',
1115 'libgoogleanalytics',
1116 'admob-sdk-android',
1118 'googleadmobadssdk',
1119 'google-play-services',
1123 'youtubeandroidplayerapi',
1128 def getpaths(field):
1130 if field not in thisbuild:
1132 for p in thisbuild[field]:
1136 elif p.startswith('./'):
1138 elif not p.startswith('/'):
1144 scanignore = getpaths('scanignore')
1145 scandelete = getpaths('scandelete')
1148 ms = magic.open(magic.MIME_TYPE)
1150 except AttributeError:
1154 for i in scanignore:
1155 if fd.startswith(i):
1160 for i in scandelete:
1161 if fd.startswith(i):
1165 def removeproblem(what, fd, fp):
1166 logging.info('Removing %s at %s' % (what, fd))
1169 def handleproblem(what, fd, fp):
1171 removeproblem(what, fd, fp)
1173 problems.append('Found %s at %s' % (what, fd))
1175 def warnproblem(what, fd, fp):
1176 logging.info('Warning: Found %s at %s' % (what, fd))
1178 def insidedir(path, dirname):
1179 return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1181 # Iterate through all files in the source code
1182 for r,d,f in os.walk(build_dir):
1184 if any(insidedir(r, igndir) for igndir in ('.hg', '.git', '.svn')):
1189 # Path (relative) to the file
1190 fp = os.path.join(r, curfile)
1191 fd = fp[len(build_dir):]
1193 # Check if this file has been explicitly excluded from scanning
1197 for suspect in usual_suspects:
1198 if suspect in curfile.lower():
1199 handleproblem('usual supect', fd, fp)
1201 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1202 if mime == 'application/x-sharedlib':
1203 handleproblem('shared library', fd, fp)
1204 elif mime == 'application/x-archive':
1205 handleproblem('static library', fd, fp)
1206 elif mime == 'application/x-executable':
1207 handleproblem('binary executable', fd, fp)
1208 elif mime == 'application/x-java-applet':
1209 handleproblem('Java compiled class', fd, fp)
1210 elif mime == 'application/jar' and has_extension(fp, 'apk'):
1211 removeproblem('APK file', fd, fp)
1212 elif mime == 'application/jar' or (
1213 mime == 'application/zip' and has_extension(fp, 'jar')):
1214 warnproblem('JAR file', fd, fp)
1215 elif mime == 'application/zip':
1216 warnproblem('ZIP file', fd, fp)
1218 elif has_extension(fp, 'java'):
1219 for line in file(fp):
1220 if 'DexClassLoader' in line:
1221 handleproblem('DexClassLoader', fd, fp)
1226 # Presence of a jni directory without buildjni=yes might
1227 # indicate a problem (if it's not a problem, explicitly use
1228 # buildjni=no to bypass this check)
1229 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1230 thisbuild.get('buildjni') is None):
1231 msg = 'Found jni directory, but buildjni is not enabled'
1232 problems.append(msg)
1240 self.path = os.path.join('stats', 'known_apks.txt')
1242 if os.path.exists(self.path):
1243 for line in file( self.path):
1244 t = line.rstrip().split(' ')
1246 self.apks[t[0]] = (t[1], None)
1248 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1249 self.changed = False
1251 def writeifchanged(self):
1253 if not os.path.exists('stats'):
1255 f = open(self.path, 'w')
1257 for apk, app in self.apks.iteritems():
1259 line = apk + ' ' + appid
1261 line += ' ' + time.strftime('%Y-%m-%d', added)
1263 for line in sorted(lst):
1264 f.write(line + '\n')
1267 # Record an apk (if it's new, otherwise does nothing)
1268 # Returns the date it was added.
1269 def recordapk(self, apk, app):
1270 if not apk in self.apks:
1271 self.apks[apk] = (app, time.gmtime(time.time()))
1273 _, added = self.apks[apk]
1276 # Look up information - given the 'apkname', returns (app id, date added/None).
1277 # Or returns None for an unknown apk.
1278 def getapp(self, apkname):
1279 if apkname in self.apks:
1280 return self.apks[apkname]
1283 # Get the most recent 'num' apps added to the repo, as a list of package ids
1284 # with the most recent first.
1285 def getlatest(self, num):
1287 for apk, app in self.apks.iteritems():
1291 if apps[appid] > added:
1295 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1296 lst = [app for app,added in sortedapps]
1300 def isApkDebuggable(apkfile, config):
1301 """Returns True if the given apk file is debuggable
1303 :param apkfile: full path to the apk to check"""
1305 p = SilentPopen([os.path.join(config['sdk_path'],
1306 'build-tools', config['build_tools'], 'aapt'),
1307 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1308 if p.returncode != 0:
1309 logging.critical("Failed to get apk manifest information")
1311 for line in p.stdout.splitlines():
1312 if 'android:debuggable' in line and not line.endswith('0x0'):
1317 class AsynchronousFileReader(threading.Thread):
1319 Helper class to implement asynchronous reading of a file
1320 in a separate thread. Pushes read lines on a queue to
1321 be consumed in another thread.
1324 def __init__(self, fd, queue):
1325 assert isinstance(queue, Queue.Queue)
1326 assert callable(fd.readline)
1327 threading.Thread.__init__(self)
1332 '''The body of the tread: read lines and put them on the queue.'''
1333 for line in iter(self._fd.readline, ''):
1334 self._queue.put(line)
1337 '''Check whether there is no more content to expect.'''
1338 return not self.is_alive() and self._queue.empty()
1344 def SilentPopen(commands, cwd=None, shell=False):
1346 Run a command silently and capture the output.
1348 :param commands: command and argument list like in subprocess.Popen
1349 :param cwd: optionally specifies a working directory
1350 :returns: A Popen object.
1354 cwd = os.path.normpath(cwd)
1355 logging.debug("Directory: %s" % cwd)
1356 logging.debug("> %s" % ' '.join(commands))
1358 result = PopenResult()
1359 p = subprocess.Popen(commands, cwd=cwd, shell=shell,
1360 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1362 result.stdout = p.communicate()[0]
1363 result.returncode = p.returncode
1366 def FDroidPopen(commands, cwd=None, shell=False):
1368 Run a command and capture the possibly huge output.
1370 :param commands: command and argument list like in subprocess.Popen
1371 :param cwd: optionally specifies a working directory
1372 :returns: A PopenResult.
1376 cwd = os.path.normpath(cwd)
1377 logging.info("Directory: %s" % cwd)
1378 logging.info("> %s" % ' '.join(commands))
1380 result = PopenResult()
1381 p = subprocess.Popen(commands, cwd=cwd, shell=shell,
1382 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1384 stdout_queue = Queue.Queue()
1385 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1386 stdout_reader.start()
1388 # Check the queue for output (until there is no more to get)
1389 while not stdout_reader.eof():
1390 while not stdout_queue.empty():
1391 line = stdout_queue.get()
1393 # Output directly to console
1394 sys.stdout.write(line)
1396 result.stdout += line
1401 result.returncode = p.returncode
1404 def remove_signing_keys(build_dir):
1405 comment = re.compile(r'[ ]*//')
1406 signing_configs = re.compile(r'[\t ]*signingConfigs[ \t]*{[ \t]*$')
1407 r_open = re.compile(r'.*{[\t ]*$')
1408 r_close = re.compile(r'.*}[\t ]*$')
1409 for root, dirs, files in os.walk(build_dir):
1410 if 'build.gradle' in files:
1411 path = os.path.join(root, 'build.gradle')
1414 with open(path, "r") as o:
1415 lines = o.readlines()
1418 with open(path, "w") as o:
1420 if comment.match(line):
1422 elif signing_configs.match(line):
1426 if r_open.match(line):
1428 elif r_close.match(line):
1430 elif any(s in line for s in (
1432 'android.signingConfigs.',
1433 'variant.outputFile = ',
1441 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1443 for propfile in ('build.properties', 'default.properties', 'ant.properties'):
1444 if propfile in files:
1445 path = os.path.join(root, propfile)
1448 with open(path, "r") as o:
1449 lines = o.readlines()
1451 with open(path, "w") as o:
1453 if line.startswith('key.store'):
1459 logging.info("Cleaned %s of keysigning configs at %s" % (propfile,path))
1461 def replace_config_vars(cmd):
1462 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1463 cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1464 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1467 def place_srclib(root_dir, number, libpath):
1470 relpath = os.path.relpath(libpath, root_dir)
1471 proppath = os.path.join(root_dir, 'project.properties')
1474 if os.path.isfile(proppath):
1475 with open(proppath, "r") as o:
1476 lines = o.readlines()
1478 with open(proppath, "w") as o:
1481 if line.startswith('android.library.reference.%d=' % number):
1482 o.write('android.library.reference.%d=%s\n' % (number,relpath))
1487 o.write('android.library.reference.%d=%s\n' % (number,relpath))