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/>.
33 from distutils.version import LooseVersion
43 'sdk_path': "$ANDROID_HOME",
44 'ndk_path': "$ANDROID_NDK",
45 'build_tools': "21.1.2",
49 'sync_from_local_copy_dir': False,
50 'make_current_version_link': True,
51 'current_version_name_source': 'Name',
52 'update_stats': False,
56 'stats_to_carbon': False,
58 'build_server_always': False,
59 'keystore': os.path.join("$HOME", '.local', 'share', 'fdroidserver', 'keystore.jks'),
60 'smartcardoptions': [],
66 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
67 'repo_name': "My First FDroid Repo Demo",
68 'repo_icon': "fdroid-icon.png",
69 'repo_description': '''
70 This is a repository of apps to be used with FDroid. Applications in this
71 repository are either official binaries built by the original application
72 developers, or are binaries built from source by the admin of f-droid.org
73 using the tools on https://gitlab.com/u/fdroid.
79 def fill_config_defaults(thisconfig):
80 for k, v in default_config.items():
81 if k not in thisconfig:
84 # Expand paths (~users and $vars)
85 for k in ['sdk_path', 'ndk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
88 v = os.path.expanduser(v)
89 v = os.path.expandvars(v)
92 thisconfig[k + '_orig'] = orig
95 def read_config(opts, config_file='config.py'):
96 """Read the repository config
98 The config is read from config_file, which is in the current directory when
99 any of the repo management commands are used.
101 global config, options, env
103 if config is not None:
105 if not os.path.isfile(config_file):
106 logging.critical("Missing config file - is this a repo directory?")
113 logging.debug("Reading %s" % config_file)
114 execfile(config_file, config)
116 # smartcardoptions must be a list since its command line args for Popen
117 if 'smartcardoptions' in config:
118 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
119 elif 'keystore' in config and config['keystore'] == 'NONE':
120 # keystore='NONE' means use smartcard, these are required defaults
121 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
122 'SunPKCS11-OpenSC', '-providerClass',
123 'sun.security.pkcs11.SunPKCS11',
124 '-providerArg', 'opensc-fdroid.cfg']
126 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
127 st = os.stat(config_file)
128 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
129 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
131 fill_config_defaults(config)
133 if not test_build_tools_exists(config):
138 os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'aapt'),
141 os.path.join(config['sdk_path'], 'tools', 'zipalign'),
142 os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'zipalign'),
145 os.path.join(config['sdk_path'], 'tools', 'android'),
148 os.path.join(config['sdk_path'], 'platform-tools', 'adb'),
152 for b, paths in bin_paths.items():
155 if os.path.isfile(path):
158 if config[b] is None:
159 logging.warn("Could not find %s in any of the following paths:\n%s" % (
160 b, '\n'.join(paths)))
162 # There is no standard, so just set up the most common environment
165 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
166 env[n] = config['sdk_path']
167 for n in ['ANDROID_NDK', 'NDK']:
168 env[n] = config['ndk_path']
170 for k in ["keystorepass", "keypass"]:
172 write_password_file(k)
174 for k in ["repo_description", "archive_description"]:
176 config[k] = clean_description(config[k])
178 if 'serverwebroot' in config:
179 if isinstance(config['serverwebroot'], basestring):
180 roots = [config['serverwebroot']]
181 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
182 roots = config['serverwebroot']
184 raise TypeError('only accepts strings, lists, and tuples')
186 for rootstr in roots:
187 # since this is used with rsync, where trailing slashes have
188 # meaning, ensure there is always a trailing slash
189 if rootstr[-1] != '/':
191 rootlist.append(rootstr.replace('//', '/'))
192 config['serverwebroot'] = rootlist
197 def test_sdk_exists(thisconfig):
198 if thisconfig['sdk_path'] == default_config['sdk_path']:
199 logging.error('No Android SDK found!')
200 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
201 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
203 if not os.path.exists(thisconfig['sdk_path']):
204 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
206 if not os.path.isdir(thisconfig['sdk_path']):
207 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
209 for d in ['build-tools', 'platform-tools', 'tools']:
210 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
211 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
212 thisconfig['sdk_path'], d))
217 def test_build_tools_exists(thisconfig):
218 if not test_sdk_exists(thisconfig):
220 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
221 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
222 if not os.path.isdir(versioned_build_tools):
223 logging.critical('Android Build Tools path "'
224 + versioned_build_tools + '" does not exist!')
229 def write_password_file(pwtype, password=None):
231 writes out passwords to a protected file instead of passing passwords as
232 command line argments
234 filename = '.fdroid.' + pwtype + '.txt'
235 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
237 os.write(fd, config[pwtype])
239 os.write(fd, password)
241 config[pwtype + 'file'] = filename
244 # Given the arguments in the form of multiple appid:[vc] strings, this returns
245 # a dictionary with the set of vercodes specified for each package.
246 def read_pkg_args(args, allow_vercodes=False):
253 if allow_vercodes and ':' in p:
254 package, vercode = p.split(':')
256 package, vercode = p, None
257 if package not in vercodes:
258 vercodes[package] = [vercode] if vercode else []
260 elif vercode and vercode not in vercodes[package]:
261 vercodes[package] += [vercode] if vercode else []
266 # On top of what read_pkg_args does, this returns the whole app metadata, but
267 # limiting the builds list to the builds matching the vercodes specified.
268 def read_app_args(args, allapps, allow_vercodes=False):
270 vercodes = read_pkg_args(args, allow_vercodes)
276 for appid, app in allapps.iteritems():
277 if appid in vercodes:
280 if len(apps) != len(vercodes):
283 logging.critical("No such package: %s" % p)
284 raise FDroidException("Found invalid app ids in arguments")
286 raise FDroidException("No packages specified")
289 for appid, app in apps.iteritems():
293 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
294 if len(app['builds']) != len(vercodes[appid]):
296 allvcs = [b['vercode'] for b in app['builds']]
297 for v in vercodes[appid]:
299 logging.critical("No such vercode %s for app %s" % (v, appid))
302 raise FDroidException("Found invalid vercodes for some apps")
307 def has_extension(filename, extension):
308 name, ext = os.path.splitext(filename)
309 ext = ext.lower()[1:]
310 return ext == extension
315 def clean_description(description):
316 'Remove unneeded newlines and spaces from a block of description text'
318 # this is split up by paragraph to make removing the newlines easier
319 for paragraph in re.split(r'\n\n', description):
320 paragraph = re.sub('\r', '', paragraph)
321 paragraph = re.sub('\n', ' ', paragraph)
322 paragraph = re.sub(' {2,}', ' ', paragraph)
323 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
324 returnstring += paragraph + '\n\n'
325 return returnstring.rstrip('\n')
328 def apknameinfo(filename):
330 filename = os.path.basename(filename)
331 if apk_regex is None:
332 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
333 m = apk_regex.match(filename)
335 result = (m.group(1), m.group(2))
336 except AttributeError:
337 raise FDroidException("Invalid apk name: %s" % filename)
341 def getapkname(app, build):
342 return "%s_%s.apk" % (app['id'], build['vercode'])
345 def getsrcname(app, build):
346 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
353 return app['Auto Name']
358 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
361 def getvcs(vcstype, remote, local):
363 return vcs_git(remote, local)
364 if vcstype == 'git-svn':
365 return vcs_gitsvn(remote, local)
367 return vcs_hg(remote, local)
369 return vcs_bzr(remote, local)
370 if vcstype == 'srclib':
371 if local != os.path.join('build', 'srclib', remote):
372 raise VCSException("Error: srclib paths are hard-coded!")
373 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
375 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
376 raise VCSException("Invalid vcs type " + vcstype)
379 def getsrclibvcs(name):
380 if name not in metadata.srclibs:
381 raise VCSException("Missing srclib " + name)
382 return metadata.srclibs[name]['Repo Type']
386 def __init__(self, remote, local):
388 # svn, git-svn and bzr may require auth
390 if self.repotype() in ('git-svn', 'bzr'):
392 self.username, remote = remote.split('@')
393 if ':' not in self.username:
394 raise VCSException("Password required with username")
395 self.username, self.password = self.username.split(':')
399 self.clone_failed = False
400 self.refreshed = False
406 # Take the local repository to a clean version of the given revision, which
407 # is specificed in the VCS's native format. Beforehand, the repository can
408 # be dirty, or even non-existent. If the repository does already exist
409 # locally, it will be updated from the origin, but only once in the
410 # lifetime of the vcs object.
411 # None is acceptable for 'rev' if you know you are cloning a clean copy of
412 # the repo - otherwise it must specify a valid revision.
413 def gotorevision(self, rev):
415 if self.clone_failed:
416 raise VCSException("Downloading the repository already failed once, not trying again.")
418 # The .fdroidvcs-id file for a repo tells us what VCS type
419 # and remote that directory was created from, allowing us to drop it
420 # automatically if either of those things changes.
421 fdpath = os.path.join(self.local, '..',
422 '.fdroidvcs-' + os.path.basename(self.local))
423 cdata = self.repotype() + ' ' + self.remote
426 if os.path.exists(self.local):
427 if os.path.exists(fdpath):
428 with open(fdpath, 'r') as f:
429 fsdata = f.read().strip()
435 "Repository details for %s changed - deleting" % (
439 logging.info("Repository details for %s missing - deleting" % (
442 shutil.rmtree(self.local)
447 self.gotorevisionx(rev)
448 except FDroidException, e:
451 # If necessary, write the .fdroidvcs file.
452 if writeback and not self.clone_failed:
453 with open(fdpath, 'w') as f:
459 # Derived classes need to implement this. It's called once basic checking
460 # has been performend.
461 def gotorevisionx(self, rev):
462 raise VCSException("This VCS type doesn't define gotorevisionx")
464 # Initialise and update submodules
465 def initsubmodules(self):
466 raise VCSException('Submodules not supported for this vcs type')
468 # Get a list of all known tags
470 raise VCSException('gettags not supported for this vcs type')
472 # Get a list of latest number tags
473 def latesttags(self, number):
474 raise VCSException('latesttags not supported for this vcs type')
476 # Get current commit reference (hash, revision, etc)
478 raise VCSException('getref not supported for this vcs type')
480 # Returns the srclib (name, path) used in setting up the current
491 # If the local directory exists, but is somehow not a git repository, git
492 # will traverse up the directory tree until it finds one that is (i.e.
493 # fdroidserver) and then we'll proceed to destroy it! This is called as
496 p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
497 result = p.output.rstrip()
498 if not result.endswith(self.local):
499 raise VCSException('Repository mismatch')
501 def gotorevisionx(self, rev):
502 if not os.path.exists(self.local):
504 p = FDroidPopen(['git', 'clone', self.remote, self.local])
505 if p.returncode != 0:
506 self.clone_failed = True
507 raise VCSException("Git clone failed", p.output)
511 # Discard any working tree changes
512 p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
513 if p.returncode != 0:
514 raise VCSException("Git reset failed", p.output)
515 # Remove untracked files now, in case they're tracked in the target
516 # revision (it happens!)
517 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
518 if p.returncode != 0:
519 raise VCSException("Git clean failed", p.output)
520 if not self.refreshed:
521 # Get latest commits and tags from remote
522 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
523 if p.returncode != 0:
524 raise VCSException("Git fetch failed", p.output)
525 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
526 if p.returncode != 0:
527 raise VCSException("Git fetch failed", p.output)
528 # Recreate origin/HEAD as git clone would do it, in case it disappeared
529 p = SilentPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local)
530 if p.returncode != 0:
531 lines = p.output.splitlines()
532 if 'Multiple remote HEAD branches' not in lines[0]:
533 raise VCSException("Git remote set-head failed", p.output)
534 branch = lines[1].split(' ')[-1]
535 p2 = SilentPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local)
536 if p2.returncode != 0:
537 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
538 self.refreshed = True
539 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
540 # a github repo. Most of the time this is the same as origin/master.
541 rev = rev or 'origin/HEAD'
542 p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
543 if p.returncode != 0:
544 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
545 # Get rid of any uncontrolled files left behind
546 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
547 if p.returncode != 0:
548 raise VCSException("Git clean failed", p.output)
550 def initsubmodules(self):
552 submfile = os.path.join(self.local, '.gitmodules')
553 if not os.path.isfile(submfile):
554 raise VCSException("No git submodules available")
556 # fix submodules not accessible without an account and public key auth
557 with open(submfile, 'r') as f:
558 lines = f.readlines()
559 with open(submfile, 'w') as f:
561 if 'git@github.com' in line:
562 line = line.replace('git@github.com:', 'https://github.com/')
566 ['git', 'reset', '--hard'],
567 ['git', 'clean', '-dffx'],
569 p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
570 if p.returncode != 0:
571 raise VCSException("Git submodule reset failed", p.output)
572 p = SilentPopen(['git', 'submodule', 'sync'], cwd=self.local)
573 if p.returncode != 0:
574 raise VCSException("Git submodule sync failed", p.output)
575 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
576 if p.returncode != 0:
577 raise VCSException("Git submodule update failed", p.output)
581 p = SilentPopen(['git', 'tag'], cwd=self.local)
582 return p.output.splitlines()
584 def latesttags(self, alltags, number):
586 p = SilentPopen(['echo "' + '\n'.join(alltags) + '" | '
587 + 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
588 + 'sort -n | awk \'{print $2}\''],
589 cwd=self.local, shell=True)
590 return p.output.splitlines()[-number:]
593 class vcs_gitsvn(vcs):
598 # Damn git-svn tries to use a graphical password prompt, so we have to
599 # trick it into taking the password from stdin
601 if self.username is None:
603 return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
605 # If the local directory exists, but is somehow not a git repository, git
606 # will traverse up the directory tree until it finds one that is (i.e.
607 # fdroidserver) and then we'll proceed to destory it! This is called as
610 p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
611 result = p.output.rstrip()
612 if not result.endswith(self.local):
613 raise VCSException('Repository mismatch')
615 def gotorevisionx(self, rev):
616 if not os.path.exists(self.local):
618 gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
619 if ';' in self.remote:
620 remote_split = self.remote.split(';')
621 for i in remote_split[1:]:
622 if i.startswith('trunk='):
623 gitsvn_cmd += ' -T %s' % i[6:]
624 elif i.startswith('tags='):
625 gitsvn_cmd += ' -t %s' % i[5:]
626 elif i.startswith('branches='):
627 gitsvn_cmd += ' -b %s' % i[9:]
628 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
629 if p.returncode != 0:
630 self.clone_failed = True
631 raise VCSException("Git svn clone failed", p.output)
633 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
634 if p.returncode != 0:
635 self.clone_failed = True
636 raise VCSException("Git svn clone failed", p.output)
640 # Discard any working tree changes
641 p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
642 if p.returncode != 0:
643 raise VCSException("Git reset failed", p.output)
644 # Remove untracked files now, in case they're tracked in the target
645 # revision (it happens!)
646 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
647 if p.returncode != 0:
648 raise VCSException("Git clean failed", p.output)
649 if not self.refreshed:
650 # Get new commits, branches and tags from repo
651 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
652 if p.returncode != 0:
653 raise VCSException("Git svn fetch failed")
654 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
655 if p.returncode != 0:
656 raise VCSException("Git svn rebase failed", p.output)
657 self.refreshed = True
659 rev = rev or 'master'
661 nospaces_rev = rev.replace(' ', '%20')
662 # Try finding a svn tag
663 for treeish in ['origin/', '']:
664 p = SilentPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev],
666 if p.returncode == 0:
668 if p.returncode != 0:
669 # No tag found, normal svn rev translation
670 # Translate svn rev into git format
671 rev_split = rev.split('/')
674 for treeish in ['origin/', '']:
675 if len(rev_split) > 1:
676 treeish += rev_split[0]
677 svn_rev = rev_split[1]
680 # if no branch is specified, then assume trunk (i.e. 'master' branch):
684 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
686 p = SilentPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish],
688 git_rev = p.output.rstrip()
690 if p.returncode == 0 and git_rev:
693 if p.returncode != 0 or not git_rev:
694 # Try a plain git checkout as a last resort
695 p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
696 if p.returncode != 0:
697 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
699 # Check out the git rev equivalent to the svn rev
700 p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
701 if p.returncode != 0:
702 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
704 # Get rid of any uncontrolled files left behind
705 p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
706 if p.returncode != 0:
707 raise VCSException("Git clean failed", p.output)
711 for treeish in ['origin/', '']:
712 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
718 p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
719 if p.returncode != 0:
721 return p.output.strip()
729 def gotorevisionx(self, rev):
730 if not os.path.exists(self.local):
731 p = SilentPopen(['hg', 'clone', self.remote, self.local])
732 if p.returncode != 0:
733 self.clone_failed = True
734 raise VCSException("Hg clone failed", p.output)
736 p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
737 if p.returncode != 0:
738 raise VCSException("Hg clean failed", p.output)
739 if not self.refreshed:
740 p = SilentPopen(['hg', 'pull'], cwd=self.local)
741 if p.returncode != 0:
742 raise VCSException("Hg pull failed", p.output)
743 self.refreshed = True
745 rev = rev or 'default'
748 p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
749 if p.returncode != 0:
750 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
751 p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
752 # Also delete untracked files, we have to enable purge extension for that:
753 if "'purge' is provided by the following extension" in p.output:
754 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
755 myfile.write("\n[extensions]\nhgext.purge=\n")
756 p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
757 if p.returncode != 0:
758 raise VCSException("HG purge failed", p.output)
759 elif p.returncode != 0:
760 raise VCSException("HG purge failed", p.output)
763 p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
764 return p.output.splitlines()[1:]
772 def gotorevisionx(self, rev):
773 if not os.path.exists(self.local):
774 p = SilentPopen(['bzr', 'branch', self.remote, self.local])
775 if p.returncode != 0:
776 self.clone_failed = True
777 raise VCSException("Bzr branch failed", p.output)
779 p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
780 if p.returncode != 0:
781 raise VCSException("Bzr revert failed", p.output)
782 if not self.refreshed:
783 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
784 if p.returncode != 0:
785 raise VCSException("Bzr update failed", p.output)
786 self.refreshed = True
788 revargs = list(['-r', rev] if rev else [])
789 p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
790 if p.returncode != 0:
791 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
794 p = SilentPopen(['bzr', 'tags'], cwd=self.local)
795 return [tag.split(' ')[0].strip() for tag in
796 p.output.splitlines()]
799 def retrieve_string(app_dir, string, xmlfiles=None):
802 os.path.join(app_dir, 'res'),
803 os.path.join(app_dir, 'src', 'main'),
808 for res_dir in res_dirs:
809 for r, d, f in os.walk(res_dir):
810 if os.path.basename(r) == 'values':
811 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
814 if string.startswith('@string/'):
815 string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
816 elif string.startswith('&') and string.endswith(';'):
817 string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
819 if string_search is not None:
820 for xmlfile in xmlfiles:
821 for line in file(xmlfile):
822 matches = string_search(line)
824 return retrieve_string(app_dir, matches.group(1), xmlfiles)
827 return string.replace("\\'", "'")
830 # Return list of existing files that will be used to find the highest vercode
831 def manifest_paths(app_dir, flavours):
833 possible_manifests = \
834 [os.path.join(app_dir, 'AndroidManifest.xml'),
835 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
836 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
837 os.path.join(app_dir, 'build.gradle')]
839 for flavour in flavours:
842 possible_manifests.append(
843 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
845 return [path for path in possible_manifests if os.path.isfile(path)]
848 # Retrieve the package name. Returns the name, or None if not found.
849 def fetch_real_name(app_dir, flavours):
850 app_search = re.compile(r'.*<application.*').search
851 name_search = re.compile(r'.*android:label="([^"]+)".*').search
853 for f in manifest_paths(app_dir, flavours):
854 if not has_extension(f, 'xml'):
856 logging.debug("fetch_real_name: Checking manifest at " + f)
862 matches = name_search(line)
864 stringname = matches.group(1)
865 logging.debug("fetch_real_name: using string " + stringname)
866 result = retrieve_string(app_dir, stringname)
868 result = result.strip()
873 # Retrieve the version name
874 def version_name(original, app_dir, flavours):
875 for f in manifest_paths(app_dir, flavours):
876 if not has_extension(f, 'xml'):
878 string = retrieve_string(app_dir, original)
884 def get_library_references(root_dir):
886 proppath = os.path.join(root_dir, 'project.properties')
887 if not os.path.isfile(proppath):
889 with open(proppath) as f:
890 for line in f.readlines():
891 if not line.startswith('android.library.reference.'):
893 path = line.split('=')[1].strip()
894 relpath = os.path.join(root_dir, path)
895 if not os.path.isdir(relpath):
897 logging.debug("Found subproject at %s" % path)
898 libraries.append(path)
902 def ant_subprojects(root_dir):
903 subprojects = get_library_references(root_dir)
904 for subpath in subprojects:
905 subrelpath = os.path.join(root_dir, subpath)
906 for p in get_library_references(subrelpath):
907 relp = os.path.normpath(os.path.join(subpath, p))
908 if relp not in subprojects:
909 subprojects.insert(0, relp)
913 def remove_debuggable_flags(root_dir):
914 # Remove forced debuggable flags
915 logging.debug("Removing debuggable flags from %s" % root_dir)
916 for root, dirs, files in os.walk(root_dir):
917 if 'AndroidManifest.xml' in files:
918 path = os.path.join(root, 'AndroidManifest.xml')
919 p = SilentPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path])
920 if p.returncode != 0:
921 raise BuildException("Failed to remove debuggable flags of %s" % path)
924 # Extract some information from the AndroidManifest.xml at the given path.
925 # Returns (version, vercode, package), any or all of which might be None.
926 # All values returned are strings.
927 def parse_androidmanifests(paths, ignoreversions=None):
930 return (None, None, None)
932 vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
933 vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
934 psearch = re.compile(r'.*package="([^"]+)".*').search
936 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
937 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
938 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
940 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
948 gradle = has_extension(path, 'gradle')
951 # Remember package name, may be defined separately from version+vercode
952 package = max_package
954 for line in file(path):
957 matches = psearch_g(line)
959 matches = psearch(line)
961 package = matches.group(1)
964 matches = vnsearch_g(line)
966 matches = vnsearch(line)
968 version = matches.group(2 if gradle else 1)
971 matches = vcsearch_g(line)
973 matches = vcsearch(line)
975 vercode = matches.group(1)
977 # Always grab the package name and version name in case they are not
978 # together with the highest version code
979 if max_package is None and package is not None:
980 max_package = package
981 if max_version is None and version is not None:
982 max_version = version
984 if max_vercode is None or (vercode is not None and vercode > max_vercode):
985 if not ignoresearch or not ignoresearch(version):
986 if version is not None:
987 max_version = version
988 if vercode is not None:
989 max_vercode = vercode
990 if package is not None:
991 max_package = package
993 max_version = "Ignore"
995 if max_version is None:
996 max_version = "Unknown"
998 return (max_version, max_vercode, max_package)
1001 class FDroidException(Exception):
1002 def __init__(self, value, detail=None):
1004 self.detail = detail
1006 def get_wikitext(self):
1007 ret = repr(self.value) + "\n"
1011 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1019 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1023 class VCSException(FDroidException):
1027 class BuildException(FDroidException):
1031 # Get the specified source library.
1032 # Returns the path to it. Normally this is the path to be used when referencing
1033 # it, which may be a subdirectory of the actual project. If you want the base
1034 # directory of the project, pass 'basepath=True'.
1035 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1036 basepath=False, raw=False, prepare=True, preponly=False):
1044 name, ref = spec.split('@')
1046 number, name = name.split(':', 1)
1048 name, subdir = name.split('/', 1)
1050 if name not in metadata.srclibs:
1051 raise VCSException('srclib ' + name + ' not found.')
1053 srclib = metadata.srclibs[name]
1055 sdir = os.path.join(srclib_dir, name)
1058 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1059 vcs.srclib = (name, number, sdir)
1061 vcs.gotorevision(ref)
1068 libdir = os.path.join(sdir, subdir)
1069 elif srclib["Subdir"]:
1070 for subdir in srclib["Subdir"]:
1071 libdir_candidate = os.path.join(sdir, subdir)
1072 if os.path.exists(libdir_candidate):
1073 libdir = libdir_candidate
1079 if srclib["Srclibs"]:
1081 for lib in srclib["Srclibs"].replace(';', ',').split(','):
1083 for t in srclibpaths:
1088 raise VCSException('Missing recursive srclib %s for %s' % (
1090 place_srclib(libdir, n, s_tuple[2])
1093 remove_signing_keys(sdir)
1094 remove_debuggable_flags(sdir)
1098 if srclib["Prepare"]:
1099 cmd = replace_config_vars(srclib["Prepare"])
1101 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1102 if p.returncode != 0:
1103 raise BuildException("Error running prepare command for srclib %s"
1109 return (name, number, libdir)
1112 # Prepare the source code for a particular build
1113 # 'vcs' - the appropriate vcs object for the application
1114 # 'app' - the application details from the metadata
1115 # 'build' - the build details from the metadata
1116 # 'build_dir' - the path to the build directory, usually
1118 # 'srclib_dir' - the path to the source libraries directory, usually
1120 # 'extlib_dir' - the path to the external libraries directory, usually
1122 # Returns the (root, srclibpaths) where:
1123 # 'root' is the root directory, which may be the same as 'build_dir' or may
1124 # be a subdirectory of it.
1125 # 'srclibpaths' is information on the srclibs being used
1126 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1128 # Optionally, the actual app source can be in a subdirectory
1130 root_dir = os.path.join(build_dir, build['subdir'])
1132 root_dir = build_dir
1134 # Get a working copy of the right revision
1135 logging.info("Getting source for revision " + build['commit'])
1136 vcs.gotorevision(build['commit'])
1138 # Initialise submodules if requred
1139 if build['submodules']:
1140 logging.info("Initialising submodules")
1141 vcs.initsubmodules()
1143 # Check that a subdir (if we're using one) exists. This has to happen
1144 # after the checkout, since it might not exist elsewhere
1145 if not os.path.exists(root_dir):
1146 raise BuildException('Missing subdir ' + root_dir)
1148 # Run an init command if one is required
1150 cmd = replace_config_vars(build['init'])
1151 logging.info("Running 'init' commands in %s" % root_dir)
1153 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1154 if p.returncode != 0:
1155 raise BuildException("Error running init command for %s:%s" %
1156 (app['id'], build['version']), p.output)
1158 # Apply patches if any
1160 logging.info("Applying patches")
1161 for patch in build['patch']:
1162 patch = patch.strip()
1163 logging.info("Applying " + patch)
1164 patch_path = os.path.join('metadata', app['id'], patch)
1165 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1166 if p.returncode != 0:
1167 raise BuildException("Failed to apply patch %s" % patch_path)
1169 # Get required source libraries
1171 if build['srclibs']:
1172 logging.info("Collecting source libraries")
1173 for lib in build['srclibs']:
1174 srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1177 for name, number, libpath in srclibpaths:
1178 place_srclib(root_dir, int(number) if number else None, libpath)
1180 basesrclib = vcs.getsrclib()
1181 # If one was used for the main source, add that too.
1183 srclibpaths.append(basesrclib)
1185 # Update the local.properties file
1186 localprops = [os.path.join(build_dir, 'local.properties')]
1188 localprops += [os.path.join(root_dir, 'local.properties')]
1189 for path in localprops:
1191 if os.path.isfile(path):
1192 logging.info("Updating local.properties file at %s" % path)
1198 logging.info("Creating local.properties file at %s" % path)
1199 # Fix old-fashioned 'sdk-location' by copying
1200 # from sdk.dir, if necessary
1201 if build['oldsdkloc']:
1202 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1203 re.S | re.M).group(1)
1204 props += "sdk-location=%s\n" % sdkloc
1206 props += "sdk.dir=%s\n" % config['sdk_path']
1207 props += "sdk-location=%s\n" % config['sdk_path']
1208 if config['ndk_path']:
1210 props += "ndk.dir=%s\n" % config['ndk_path']
1211 props += "ndk-location=%s\n" % config['ndk_path']
1212 # Add java.encoding if necessary
1213 if build['encoding']:
1214 props += "java.encoding=%s\n" % build['encoding']
1220 if build['type'] == 'gradle':
1221 flavours = build['gradle']
1223 version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1224 gradlepluginver = None
1226 gradle_files = [os.path.join(root_dir, 'build.gradle')]
1228 # Parent dir build.gradle
1229 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1230 if parent_dir.startswith(build_dir):
1231 gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1233 for path in gradle_files:
1236 if not os.path.isfile(path):
1238 with open(path) as f:
1240 match = version_regex.match(line)
1242 gradlepluginver = match.group(1)
1246 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1248 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1249 build['gradlepluginver'] = LooseVersion('0.11')
1252 n = build["target"].split('-')[1]
1253 SilentPopen(['sed', '-i',
1254 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1258 # Remove forced debuggable flags
1259 remove_debuggable_flags(root_dir)
1261 # Insert version code and number into the manifest if necessary
1262 if build['forceversion']:
1263 logging.info("Changing the version name")
1264 for path in manifest_paths(root_dir, flavours):
1265 if not os.path.isfile(path):
1267 if has_extension(path, 'xml'):
1268 p = SilentPopen(['sed', '-i',
1269 's/android:versionName="[^"]*"/android:versionName="'
1270 + build['version'] + '"/g',
1272 if p.returncode != 0:
1273 raise BuildException("Failed to amend manifest")
1274 elif has_extension(path, 'gradle'):
1275 p = SilentPopen(['sed', '-i',
1276 's/versionName *=* *"[^"]*"/versionName = "'
1277 + build['version'] + '"/g',
1279 if p.returncode != 0:
1280 raise BuildException("Failed to amend build.gradle")
1281 if build['forcevercode']:
1282 logging.info("Changing the version code")
1283 for path in manifest_paths(root_dir, flavours):
1284 if not os.path.isfile(path):
1286 if has_extension(path, 'xml'):
1287 p = SilentPopen(['sed', '-i',
1288 's/android:versionCode="[^"]*"/android:versionCode="'
1289 + build['vercode'] + '"/g',
1291 if p.returncode != 0:
1292 raise BuildException("Failed to amend manifest")
1293 elif has_extension(path, 'gradle'):
1294 p = SilentPopen(['sed', '-i',
1295 's/versionCode *=* *[0-9]*/versionCode = '
1296 + build['vercode'] + '/g',
1298 if p.returncode != 0:
1299 raise BuildException("Failed to amend build.gradle")
1301 # Delete unwanted files
1303 logging.info("Removing specified files")
1304 for part in getpaths(build_dir, build, 'rm'):
1305 dest = os.path.join(build_dir, part)
1306 logging.info("Removing {0}".format(part))
1307 if os.path.lexists(dest):
1308 if os.path.islink(dest):
1309 SilentPopen(['unlink ' + dest], shell=True)
1311 SilentPopen(['rm -rf ' + dest], shell=True)
1313 logging.info("...but it didn't exist")
1315 remove_signing_keys(build_dir)
1317 # Add required external libraries
1318 if build['extlibs']:
1319 logging.info("Collecting prebuilt libraries")
1320 libsdir = os.path.join(root_dir, 'libs')
1321 if not os.path.exists(libsdir):
1323 for lib in build['extlibs']:
1325 logging.info("...installing extlib {0}".format(lib))
1326 libf = os.path.basename(lib)
1327 libsrc = os.path.join(extlib_dir, lib)
1328 if not os.path.exists(libsrc):
1329 raise BuildException("Missing extlib file {0}".format(libsrc))
1330 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1332 # Run a pre-build command if one is required
1333 if build['prebuild']:
1334 logging.info("Running 'prebuild' commands in %s" % root_dir)
1336 cmd = replace_config_vars(build['prebuild'])
1338 # Substitute source library paths into prebuild commands
1339 for name, number, libpath in srclibpaths:
1340 libpath = os.path.relpath(libpath, root_dir)
1341 cmd = cmd.replace('$$' + name + '$$', libpath)
1343 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1344 if p.returncode != 0:
1345 raise BuildException("Error running prebuild command for %s:%s" %
1346 (app['id'], build['version']), p.output)
1348 # Generate (or update) the ant build file, build.xml...
1349 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1350 parms = [config['android'], 'update', 'lib-project']
1351 lparms = [config['android'], 'update', 'project']
1354 parms += ['-t', build['target']]
1355 lparms += ['-t', build['target']]
1356 if build['update'] == ['auto']:
1357 update_dirs = ant_subprojects(root_dir) + ['.']
1359 update_dirs = build['update']
1361 for d in update_dirs:
1362 subdir = os.path.join(root_dir, d)
1364 logging.debug("Updating main project")
1365 cmd = parms + ['-p', d]
1367 logging.debug("Updating subproject %s" % d)
1368 cmd = lparms + ['-p', d]
1369 p = FDroidPopen(cmd, cwd=root_dir)
1370 # Check to see whether an error was returned without a proper exit
1371 # code (this is the case for the 'no target set or target invalid'
1373 if p.returncode != 0 or p.output.startswith("Error: "):
1374 raise BuildException("Failed to update project at %s" % d, p.output)
1375 # Clean update dirs via ant
1377 logging.info("Cleaning subproject %s" % d)
1378 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1380 return (root_dir, srclibpaths)
1383 # Split and extend via globbing the paths from a field
1384 def getpaths(build_dir, build, field):
1386 for p in build[field]:
1388 full_path = os.path.join(build_dir, p)
1389 full_path = os.path.normpath(full_path)
1390 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1394 # Scan the source code in the given directory (and all subdirectories)
1395 # and return the number of fatal problems encountered
1396 def scan_source(build_dir, root_dir, thisbuild):
1400 # Common known non-free blobs (always lower case):
1402 re.compile(r'flurryagent', re.IGNORECASE),
1403 re.compile(r'paypal.*mpl', re.IGNORECASE),
1404 re.compile(r'google.*analytics', re.IGNORECASE),
1405 re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1406 re.compile(r'google.*ad.*view', re.IGNORECASE),
1407 re.compile(r'google.*admob', re.IGNORECASE),
1408 re.compile(r'google.*play.*services', re.IGNORECASE),
1409 re.compile(r'crittercism', re.IGNORECASE),
1410 re.compile(r'heyzap', re.IGNORECASE),
1411 re.compile(r'jpct.*ae', re.IGNORECASE),
1412 re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1413 re.compile(r'bugsense', re.IGNORECASE),
1414 re.compile(r'crashlytics', re.IGNORECASE),
1415 re.compile(r'ouya.*sdk', re.IGNORECASE),
1416 re.compile(r'libspen23', re.IGNORECASE),
1419 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1420 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1423 ms = magic.open(magic.MIME_TYPE)
1425 except AttributeError:
1429 for i in scanignore:
1430 if fd.startswith(i):
1435 for i in scandelete:
1436 if fd.startswith(i):
1440 def removeproblem(what, fd, fp):
1441 logging.info('Removing %s at %s' % (what, fd))
1444 def warnproblem(what, fd):
1445 logging.warn('Found %s at %s' % (what, fd))
1447 def handleproblem(what, fd, fp):
1449 logging.info('Ignoring %s at %s' % (what, fd))
1451 removeproblem(what, fd, fp)
1453 logging.error('Found %s at %s' % (what, fd))
1457 # Iterate through all files in the source code
1458 for r, d, f in os.walk(build_dir, topdown=True):
1460 # It's topdown, so checking the basename is enough
1461 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1467 # Path (relative) to the file
1468 fp = os.path.join(r, curfile)
1469 fd = fp[len(build_dir) + 1:]
1472 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1473 except UnicodeError:
1474 warnproblem('malformed magic number', fd)
1476 if mime == 'application/x-sharedlib':
1477 count += handleproblem('shared library', fd, fp)
1479 elif mime == 'application/x-archive':
1480 count += handleproblem('static library', fd, fp)
1482 elif mime == 'application/x-executable':
1483 count += handleproblem('binary executable', fd, fp)
1485 elif mime == 'application/x-java-applet':
1486 count += handleproblem('Java compiled class', fd, fp)
1491 'application/java-archive',
1492 'application/octet-stream',
1496 if has_extension(fp, 'apk'):
1497 removeproblem('APK file', fd, fp)
1499 elif has_extension(fp, 'jar'):
1501 if any(suspect.match(curfile) for suspect in usual_suspects):
1502 count += handleproblem('usual supect', fd, fp)
1504 warnproblem('JAR file', fd)
1506 elif has_extension(fp, 'zip'):
1507 warnproblem('ZIP file', fd)
1510 warnproblem('unknown compressed or binary file', fd)
1512 elif has_extension(fp, 'java'):
1513 for line in file(fp):
1514 if 'DexClassLoader' in line:
1515 count += handleproblem('DexClassLoader', fd, fp)
1520 # Presence of a jni directory without buildjni=yes might
1521 # indicate a problem (if it's not a problem, explicitly use
1522 # buildjni=no to bypass this check)
1523 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1524 not thisbuild['buildjni']):
1525 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1534 self.path = os.path.join('stats', 'known_apks.txt')
1536 if os.path.exists(self.path):
1537 for line in file(self.path):
1538 t = line.rstrip().split(' ')
1540 self.apks[t[0]] = (t[1], None)
1542 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1543 self.changed = False
1545 def writeifchanged(self):
1547 if not os.path.exists('stats'):
1549 f = open(self.path, 'w')
1551 for apk, app in self.apks.iteritems():
1553 line = apk + ' ' + appid
1555 line += ' ' + time.strftime('%Y-%m-%d', added)
1557 for line in sorted(lst):
1558 f.write(line + '\n')
1561 # Record an apk (if it's new, otherwise does nothing)
1562 # Returns the date it was added.
1563 def recordapk(self, apk, app):
1564 if apk not in self.apks:
1565 self.apks[apk] = (app, time.gmtime(time.time()))
1567 _, added = self.apks[apk]
1570 # Look up information - given the 'apkname', returns (app id, date added/None).
1571 # Or returns None for an unknown apk.
1572 def getapp(self, apkname):
1573 if apkname in self.apks:
1574 return self.apks[apkname]
1577 # Get the most recent 'num' apps added to the repo, as a list of package ids
1578 # with the most recent first.
1579 def getlatest(self, num):
1581 for apk, app in self.apks.iteritems():
1585 if apps[appid] > added:
1589 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1590 lst = [app for app, _ in sortedapps]
1595 def isApkDebuggable(apkfile, config):
1596 """Returns True if the given apk file is debuggable
1598 :param apkfile: full path to the apk to check"""
1600 p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1601 config['build_tools'], 'aapt'),
1602 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1603 if p.returncode != 0:
1604 logging.critical("Failed to get apk manifest information")
1606 for line in p.output.splitlines():
1607 if 'android:debuggable' in line and not line.endswith('0x0'):
1612 class AsynchronousFileReader(threading.Thread):
1614 Helper class to implement asynchronous reading of a file
1615 in a separate thread. Pushes read lines on a queue to
1616 be consumed in another thread.
1619 def __init__(self, fd, queue):
1620 assert isinstance(queue, Queue.Queue)
1621 assert callable(fd.readline)
1622 threading.Thread.__init__(self)
1627 '''The body of the tread: read lines and put them on the queue.'''
1628 for line in iter(self._fd.readline, ''):
1629 self._queue.put(line)
1632 '''Check whether there is no more content to expect.'''
1633 return not self.is_alive() and self._queue.empty()
1641 def SilentPopen(commands, cwd=None, shell=False):
1642 return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1645 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1647 Run a command and capture the possibly huge output.
1649 :param commands: command and argument list like in subprocess.Popen
1650 :param cwd: optionally specifies a working directory
1651 :returns: A PopenResult.
1657 cwd = os.path.normpath(cwd)
1658 logging.debug("Directory: %s" % cwd)
1659 logging.debug("> %s" % ' '.join(commands))
1661 result = PopenResult()
1664 p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1665 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1667 raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
1669 stdout_queue = Queue.Queue()
1670 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1671 stdout_reader.start()
1673 # Check the queue for output (until there is no more to get)
1674 while not stdout_reader.eof():
1675 while not stdout_queue.empty():
1676 line = stdout_queue.get()
1677 if output and options.verbose:
1678 # Output directly to console
1679 sys.stderr.write(line)
1681 result.output += line
1685 result.returncode = p.wait()
1689 def remove_signing_keys(build_dir):
1690 comment = re.compile(r'[ ]*//')
1691 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1693 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1694 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1695 re.compile(r'.*variant\.outputFile = .*'),
1696 re.compile(r'.*\.readLine\(.*'),
1698 for root, dirs, files in os.walk(build_dir):
1699 if 'build.gradle' in files:
1700 path = os.path.join(root, 'build.gradle')
1702 with open(path, "r") as o:
1703 lines = o.readlines()
1708 with open(path, "w") as o:
1710 if comment.match(line):
1714 opened += line.count('{')
1715 opened -= line.count('}')
1718 if signing_configs.match(line):
1723 if any(s.match(line) for s in line_matches):
1731 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1734 'project.properties',
1736 'default.properties',
1739 if propfile in files:
1740 path = os.path.join(root, propfile)
1742 with open(path, "r") as o:
1743 lines = o.readlines()
1747 with open(path, "w") as o:
1749 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1756 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1759 def replace_config_vars(cmd):
1760 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1761 cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1762 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1766 def place_srclib(root_dir, number, libpath):
1769 relpath = os.path.relpath(libpath, root_dir)
1770 proppath = os.path.join(root_dir, 'project.properties')
1773 if os.path.isfile(proppath):
1774 with open(proppath, "r") as o:
1775 lines = o.readlines()
1777 with open(proppath, "w") as o:
1780 if line.startswith('android.library.reference.%d=' % number):
1781 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1786 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1789 def compare_apks(apk1, apk2, tmp_dir):
1792 Returns None if the apk content is the same (apart from the signing key),
1793 otherwise a string describing what's different, or what went wrong when
1794 trying to do the comparison.
1797 thisdir = os.path.join(tmp_dir, 'this_apk')
1798 thatdir = os.path.join(tmp_dir, 'that_apk')
1799 for d in [thisdir, thatdir]:
1800 if os.path.exists(d):
1804 if subprocess.call(['jar', 'xf',
1805 os.path.abspath(apk1)],
1807 return("Failed to unpack " + apk1)
1808 if subprocess.call(['jar', 'xf',
1809 os.path.abspath(apk2)],
1811 return("Failed to unpack " + apk2)
1813 p = FDroidPopen(['diff', '-r', 'this_apk', 'that_apk'], cwd=tmp_dir,
1815 lines = p.output.splitlines()
1816 if len(lines) != 1 or 'META-INF' not in lines[0]:
1817 return("Unexpected diff output - " + p.output)
1819 # If we get here, it seems like they're the same!