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/>.
35 import xml.etree.ElementTree as XMLElementTree
37 from distutils.version import LooseVersion
38 from zipfile import ZipFile
42 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
51 'sdk_path': "$ANDROID_HOME",
54 'r10e': "$ANDROID_NDK"
56 'build_tools': "22.0.1",
60 'sync_from_local_copy_dir': False,
61 'make_current_version_link': True,
62 'current_version_name_source': 'Name',
63 'update_stats': False,
67 'stats_to_carbon': False,
69 'build_server_always': False,
70 'keystore': 'keystore.jks',
71 'smartcardoptions': [],
77 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
78 'repo_name': "My First FDroid Repo Demo",
79 'repo_icon': "fdroid-icon.png",
80 'repo_description': '''
81 This is a repository of apps to be used with FDroid. Applications in this
82 repository are either official binaries built by the original application
83 developers, or are binaries built from source by the admin of f-droid.org
84 using the tools on https://gitlab.com/u/fdroid.
90 def fill_config_defaults(thisconfig):
91 for k, v in default_config.items():
92 if k not in thisconfig:
95 # Expand paths (~users and $vars)
96 def expand_path(path):
100 path = os.path.expanduser(path)
101 path = os.path.expandvars(path)
106 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
111 thisconfig[k + '_orig'] = v
113 for k in ['ndk_paths']:
119 thisconfig[k][k2] = exp
120 thisconfig[k][k2 + '_orig'] = v
123 def regsub_file(pattern, repl, path):
124 with open(path, 'r') as f:
126 text = re.sub(pattern, repl, text)
127 with open(path, 'w') as f:
131 def read_config(opts, config_file='config.py'):
132 """Read the repository config
134 The config is read from config_file, which is in the current directory when
135 any of the repo management commands are used.
137 global config, options, env, orig_path
139 if config is not None:
141 if not os.path.isfile(config_file):
142 logging.critical("Missing config file - is this a repo directory?")
149 logging.debug("Reading %s" % config_file)
150 execfile(config_file, config)
152 # smartcardoptions must be a list since its command line args for Popen
153 if 'smartcardoptions' in config:
154 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
155 elif 'keystore' in config and config['keystore'] == 'NONE':
156 # keystore='NONE' means use smartcard, these are required defaults
157 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
158 'SunPKCS11-OpenSC', '-providerClass',
159 'sun.security.pkcs11.SunPKCS11',
160 '-providerArg', 'opensc-fdroid.cfg']
162 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
163 st = os.stat(config_file)
164 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
165 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
167 fill_config_defaults(config)
169 # There is no standard, so just set up the most common environment
172 orig_path = env['PATH']
173 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
174 env[n] = config['sdk_path']
176 for k in ["keystorepass", "keypass"]:
178 write_password_file(k)
180 for k in ["repo_description", "archive_description"]:
182 config[k] = clean_description(config[k])
184 if 'serverwebroot' in config:
185 if isinstance(config['serverwebroot'], basestring):
186 roots = [config['serverwebroot']]
187 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
188 roots = config['serverwebroot']
190 raise TypeError('only accepts strings, lists, and tuples')
192 for rootstr in roots:
193 # since this is used with rsync, where trailing slashes have
194 # meaning, ensure there is always a trailing slash
195 if rootstr[-1] != '/':
197 rootlist.append(rootstr.replace('//', '/'))
198 config['serverwebroot'] = rootlist
203 def get_ndk_path(version):
205 version = 'r10e' # falls back to latest
206 paths = config['ndk_paths']
207 if version not in paths:
209 return paths[version] or ''
212 def find_sdk_tools_cmd(cmd):
213 '''find a working path to a tool from the Android SDK'''
216 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
217 # try to find a working path to this command, in all the recent possible paths
218 if 'build_tools' in config:
219 build_tools = os.path.join(config['sdk_path'], 'build-tools')
220 # if 'build_tools' was manually set and exists, check only that one
221 configed_build_tools = os.path.join(build_tools, config['build_tools'])
222 if os.path.exists(configed_build_tools):
223 tooldirs.append(configed_build_tools)
225 # no configed version, so hunt known paths for it
226 for f in sorted(os.listdir(build_tools), reverse=True):
227 if os.path.isdir(os.path.join(build_tools, f)):
228 tooldirs.append(os.path.join(build_tools, f))
229 tooldirs.append(build_tools)
230 sdk_tools = os.path.join(config['sdk_path'], 'tools')
231 if os.path.exists(sdk_tools):
232 tooldirs.append(sdk_tools)
233 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
234 if os.path.exists(sdk_platform_tools):
235 tooldirs.append(sdk_platform_tools)
236 tooldirs.append('/usr/bin')
238 if os.path.isfile(os.path.join(d, cmd)):
239 return os.path.join(d, cmd)
240 # did not find the command, exit with error message
241 ensure_build_tools_exists(config)
244 def test_sdk_exists(thisconfig):
245 if 'sdk_path' not in thisconfig:
246 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
249 logging.error("'sdk_path' not set in config.py!")
251 if thisconfig['sdk_path'] == default_config['sdk_path']:
252 logging.error('No Android SDK found!')
253 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
254 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
256 if not os.path.exists(thisconfig['sdk_path']):
257 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
259 if not os.path.isdir(thisconfig['sdk_path']):
260 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
262 for d in ['build-tools', 'platform-tools', 'tools']:
263 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
264 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
265 thisconfig['sdk_path'], d))
270 def ensure_build_tools_exists(thisconfig):
271 if not test_sdk_exists(thisconfig):
273 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
274 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
275 if not os.path.isdir(versioned_build_tools):
276 logging.critical('Android Build Tools path "'
277 + versioned_build_tools + '" does not exist!')
281 def write_password_file(pwtype, password=None):
283 writes out passwords to a protected file instead of passing passwords as
284 command line argments
286 filename = '.fdroid.' + pwtype + '.txt'
287 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
289 os.write(fd, config[pwtype])
291 os.write(fd, password)
293 config[pwtype + 'file'] = filename
296 # Given the arguments in the form of multiple appid:[vc] strings, this returns
297 # a dictionary with the set of vercodes specified for each package.
298 def read_pkg_args(args, allow_vercodes=False):
305 if allow_vercodes and ':' in p:
306 package, vercode = p.split(':')
308 package, vercode = p, None
309 if package not in vercodes:
310 vercodes[package] = [vercode] if vercode else []
312 elif vercode and vercode not in vercodes[package]:
313 vercodes[package] += [vercode] if vercode else []
318 # On top of what read_pkg_args does, this returns the whole app metadata, but
319 # limiting the builds list to the builds matching the vercodes specified.
320 def read_app_args(args, allapps, allow_vercodes=False):
322 vercodes = read_pkg_args(args, allow_vercodes)
328 for appid, app in allapps.iteritems():
329 if appid in vercodes:
332 if len(apps) != len(vercodes):
335 logging.critical("No such package: %s" % p)
336 raise FDroidException("Found invalid app ids in arguments")
338 raise FDroidException("No packages specified")
341 for appid, app in apps.iteritems():
345 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
346 if len(app['builds']) != len(vercodes[appid]):
348 allvcs = [b['vercode'] for b in app['builds']]
349 for v in vercodes[appid]:
351 logging.critical("No such vercode %s for app %s" % (v, appid))
354 raise FDroidException("Found invalid vercodes for some apps")
359 def has_extension(filename, extension):
360 name, ext = os.path.splitext(filename)
361 ext = ext.lower()[1:]
362 return ext == extension
367 def clean_description(description):
368 'Remove unneeded newlines and spaces from a block of description text'
370 # this is split up by paragraph to make removing the newlines easier
371 for paragraph in re.split(r'\n\n', description):
372 paragraph = re.sub('\r', '', paragraph)
373 paragraph = re.sub('\n', ' ', paragraph)
374 paragraph = re.sub(' {2,}', ' ', paragraph)
375 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
376 returnstring += paragraph + '\n\n'
377 return returnstring.rstrip('\n')
380 def apknameinfo(filename):
382 filename = os.path.basename(filename)
383 if apk_regex is None:
384 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
385 m = apk_regex.match(filename)
387 result = (m.group(1), m.group(2))
388 except AttributeError:
389 raise FDroidException("Invalid apk name: %s" % filename)
393 def getapkname(app, build):
394 return "%s_%s.apk" % (app['id'], build['vercode'])
397 def getsrcname(app, build):
398 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
405 return app['Auto Name']
410 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
413 def getvcs(vcstype, remote, local):
415 return vcs_git(remote, local)
416 if vcstype == 'git-svn':
417 return vcs_gitsvn(remote, local)
419 return vcs_hg(remote, local)
421 return vcs_bzr(remote, local)
422 if vcstype == 'srclib':
423 if local != os.path.join('build', 'srclib', remote):
424 raise VCSException("Error: srclib paths are hard-coded!")
425 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
427 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
428 raise VCSException("Invalid vcs type " + vcstype)
431 def getsrclibvcs(name):
432 if name not in metadata.srclibs:
433 raise VCSException("Missing srclib " + name)
434 return metadata.srclibs[name]['Repo Type']
439 def __init__(self, remote, local):
441 # svn, git-svn and bzr may require auth
443 if self.repotype() in ('git-svn', 'bzr'):
445 if self.repotype == 'git-svn':
446 raise VCSException("Authentication is not supported for git-svn")
447 self.username, remote = remote.split('@')
448 if ':' not in self.username:
449 raise VCSException("Password required with username")
450 self.username, self.password = self.username.split(':')
454 self.clone_failed = False
455 self.refreshed = False
461 # Take the local repository to a clean version of the given revision, which
462 # is specificed in the VCS's native format. Beforehand, the repository can
463 # be dirty, or even non-existent. If the repository does already exist
464 # locally, it will be updated from the origin, but only once in the
465 # lifetime of the vcs object.
466 # None is acceptable for 'rev' if you know you are cloning a clean copy of
467 # the repo - otherwise it must specify a valid revision.
468 def gotorevision(self, rev, refresh=True):
470 if self.clone_failed:
471 raise VCSException("Downloading the repository already failed once, not trying again.")
473 # The .fdroidvcs-id file for a repo tells us what VCS type
474 # and remote that directory was created from, allowing us to drop it
475 # automatically if either of those things changes.
476 fdpath = os.path.join(self.local, '..',
477 '.fdroidvcs-' + os.path.basename(self.local))
478 cdata = self.repotype() + ' ' + self.remote
481 if os.path.exists(self.local):
482 if os.path.exists(fdpath):
483 with open(fdpath, 'r') as f:
484 fsdata = f.read().strip()
489 logging.info("Repository details for %s changed - deleting" % (
493 logging.info("Repository details for %s missing - deleting" % (
496 shutil.rmtree(self.local)
500 self.refreshed = True
503 self.gotorevisionx(rev)
504 except FDroidException, e:
507 # If necessary, write the .fdroidvcs file.
508 if writeback and not self.clone_failed:
509 with open(fdpath, 'w') as f:
515 # Derived classes need to implement this. It's called once basic checking
516 # has been performend.
517 def gotorevisionx(self, rev):
518 raise VCSException("This VCS type doesn't define gotorevisionx")
520 # Initialise and update submodules
521 def initsubmodules(self):
522 raise VCSException('Submodules not supported for this vcs type')
524 # Get a list of all known tags
526 if not self._gettags:
527 raise VCSException('gettags not supported for this vcs type')
529 for tag in self._gettags():
530 if re.match('[-A-Za-z0-9_. ]+$', tag):
534 def latesttags(self, tags, number):
535 """Get the most recent tags in a given list.
537 :param tags: a list of tags
538 :param number: the number to return
539 :returns: A list containing the most recent tags in the provided
540 list, up to the maximum number given.
542 raise VCSException('latesttags not supported for this vcs type')
544 # Get current commit reference (hash, revision, etc)
546 raise VCSException('getref not supported for this vcs type')
548 # Returns the srclib (name, path) used in setting up the current
559 # If the local directory exists, but is somehow not a git repository, git
560 # will traverse up the directory tree until it finds one that is (i.e.
561 # fdroidserver) and then we'll proceed to destroy it! This is called as
564 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
565 result = p.output.rstrip()
566 if not result.endswith(self.local):
567 raise VCSException('Repository mismatch')
569 def gotorevisionx(self, rev):
570 if not os.path.exists(self.local):
572 p = FDroidPopen(['git', 'clone', self.remote, self.local])
573 if p.returncode != 0:
574 self.clone_failed = True
575 raise VCSException("Git clone failed", p.output)
579 # Discard any working tree changes
580 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
581 'git', 'reset', '--hard'], cwd=self.local, output=False)
582 if p.returncode != 0:
583 raise VCSException("Git reset failed", p.output)
584 # Remove untracked files now, in case they're tracked in the target
585 # revision (it happens!)
586 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
587 'git', 'clean', '-dffx'], cwd=self.local, output=False)
588 if p.returncode != 0:
589 raise VCSException("Git clean failed", p.output)
590 if not self.refreshed:
591 # Get latest commits and tags from remote
592 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
593 if p.returncode != 0:
594 raise VCSException("Git fetch failed", p.output)
595 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
596 if p.returncode != 0:
597 raise VCSException("Git fetch failed", p.output)
598 # Recreate origin/HEAD as git clone would do it, in case it disappeared
599 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
600 if p.returncode != 0:
601 lines = p.output.splitlines()
602 if 'Multiple remote HEAD branches' not in lines[0]:
603 raise VCSException("Git remote set-head failed", p.output)
604 branch = lines[1].split(' ')[-1]
605 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
606 if p2.returncode != 0:
607 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
608 self.refreshed = True
609 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
610 # a github repo. Most of the time this is the same as origin/master.
611 rev = rev or 'origin/HEAD'
612 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
613 if p.returncode != 0:
614 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
615 # Get rid of any uncontrolled files left behind
616 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
617 if p.returncode != 0:
618 raise VCSException("Git clean failed", p.output)
620 def initsubmodules(self):
622 submfile = os.path.join(self.local, '.gitmodules')
623 if not os.path.isfile(submfile):
624 raise VCSException("No git submodules available")
626 # fix submodules not accessible without an account and public key auth
627 with open(submfile, 'r') as f:
628 lines = f.readlines()
629 with open(submfile, 'w') as f:
631 if 'git@github.com' in line:
632 line = line.replace('git@github.com:', 'https://github.com/')
635 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
636 if p.returncode != 0:
637 raise VCSException("Git submodule sync failed", p.output)
638 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
639 if p.returncode != 0:
640 raise VCSException("Git submodule update failed", p.output)
644 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
645 return p.output.splitlines()
647 def latesttags(self, tags, number):
652 ['git', 'show', '--format=format:%ct', '-s', tag],
653 cwd=self.local, output=False)
654 # Timestamp is on the last line. For a normal tag, it's the only
655 # line, but for annotated tags, the rest of the info precedes it.
656 ts = int(p.output.splitlines()[-1])
659 for _, t in sorted(tl)[-number:]:
664 class vcs_gitsvn(vcs):
669 # If the local directory exists, but is somehow not a git repository, git
670 # will traverse up the directory tree until it finds one that is (i.e.
671 # fdroidserver) and then we'll proceed to destory it! This is called as
674 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
675 result = p.output.rstrip()
676 if not result.endswith(self.local):
677 raise VCSException('Repository mismatch')
679 def gotorevisionx(self, rev):
680 if not os.path.exists(self.local):
682 gitsvn_args = ['git', 'svn', 'clone']
683 if ';' in self.remote:
684 remote_split = self.remote.split(';')
685 for i in remote_split[1:]:
686 if i.startswith('trunk='):
687 gitsvn_args.extend(['-T', i[6:]])
688 elif i.startswith('tags='):
689 gitsvn_args.extend(['-t', i[5:]])
690 elif i.startswith('branches='):
691 gitsvn_args.extend(['-b', i[9:]])
692 gitsvn_args.extend([remote_split[0], self.local])
693 p = FDroidPopen(gitsvn_args, output=False)
694 if p.returncode != 0:
695 self.clone_failed = True
696 raise VCSException("Git svn clone failed", p.output)
698 gitsvn_args.extend([self.remote, self.local])
699 p = FDroidPopen(gitsvn_args, output=False)
700 if p.returncode != 0:
701 self.clone_failed = True
702 raise VCSException("Git svn clone failed", p.output)
706 # Discard any working tree changes
707 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
708 if p.returncode != 0:
709 raise VCSException("Git reset failed", p.output)
710 # Remove untracked files now, in case they're tracked in the target
711 # revision (it happens!)
712 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
713 if p.returncode != 0:
714 raise VCSException("Git clean failed", p.output)
715 if not self.refreshed:
716 # Get new commits, branches and tags from repo
717 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
718 if p.returncode != 0:
719 raise VCSException("Git svn fetch failed")
720 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
721 if p.returncode != 0:
722 raise VCSException("Git svn rebase failed", p.output)
723 self.refreshed = True
725 rev = rev or 'master'
727 nospaces_rev = rev.replace(' ', '%20')
728 # Try finding a svn tag
729 for treeish in ['origin/', '']:
730 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
731 if p.returncode == 0:
733 if p.returncode != 0:
734 # No tag found, normal svn rev translation
735 # Translate svn rev into git format
736 rev_split = rev.split('/')
739 for treeish in ['origin/', '']:
740 if len(rev_split) > 1:
741 treeish += rev_split[0]
742 svn_rev = rev_split[1]
745 # if no branch is specified, then assume trunk (i.e. 'master' branch):
749 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
751 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
752 git_rev = p.output.rstrip()
754 if p.returncode == 0 and git_rev:
757 if p.returncode != 0 or not git_rev:
758 # Try a plain git checkout as a last resort
759 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
760 if p.returncode != 0:
761 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
763 # Check out the git rev equivalent to the svn rev
764 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
765 if p.returncode != 0:
766 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
768 # Get rid of any uncontrolled files left behind
769 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
770 if p.returncode != 0:
771 raise VCSException("Git clean failed", p.output)
775 for treeish in ['origin/', '']:
776 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
782 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
783 if p.returncode != 0:
785 return p.output.strip()
793 def gotorevisionx(self, rev):
794 if not os.path.exists(self.local):
795 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
796 if p.returncode != 0:
797 self.clone_failed = True
798 raise VCSException("Hg clone failed", p.output)
800 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
801 if p.returncode != 0:
802 raise VCSException("Hg status failed", p.output)
803 for line in p.output.splitlines():
804 if not line.startswith('? '):
805 raise VCSException("Unexpected output from hg status -uS: " + line)
806 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
807 if not self.refreshed:
808 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
809 if p.returncode != 0:
810 raise VCSException("Hg pull failed", p.output)
811 self.refreshed = True
813 rev = rev or 'default'
816 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
817 if p.returncode != 0:
818 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
819 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
820 # Also delete untracked files, we have to enable purge extension for that:
821 if "'purge' is provided by the following extension" in p.output:
822 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
823 myfile.write("\n[extensions]\nhgext.purge=\n")
824 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
825 if p.returncode != 0:
826 raise VCSException("HG purge failed", p.output)
827 elif p.returncode != 0:
828 raise VCSException("HG purge failed", p.output)
831 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
832 return p.output.splitlines()[1:]
840 def gotorevisionx(self, rev):
841 if not os.path.exists(self.local):
842 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
843 if p.returncode != 0:
844 self.clone_failed = True
845 raise VCSException("Bzr branch failed", p.output)
847 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
848 if p.returncode != 0:
849 raise VCSException("Bzr revert failed", p.output)
850 if not self.refreshed:
851 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
852 if p.returncode != 0:
853 raise VCSException("Bzr update failed", p.output)
854 self.refreshed = True
856 revargs = list(['-r', rev] if rev else [])
857 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
858 if p.returncode != 0:
859 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
862 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
863 return [tag.split(' ')[0].strip() for tag in
864 p.output.splitlines()]
867 def unescape_string(string):
868 if string[0] == '"' and string[-1] == '"':
871 return string.replace("\\'", "'")
874 def retrieve_string(app_dir, string, xmlfiles=None):
879 os.path.join(app_dir, 'res'),
880 os.path.join(app_dir, 'src', 'main', 'res'),
882 for r, d, f in os.walk(res_dir):
883 if os.path.basename(r) == 'values':
884 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
886 if not string.startswith('@string/'):
887 return unescape_string(string)
889 name = string[len('@string/'):]
891 for path in xmlfiles:
892 if not os.path.isfile(path):
894 xml = parse_xml(path)
895 element = xml.find('string[@name="' + name + '"]')
896 if element is not None and element.text is not None:
897 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
902 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
903 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
906 # Return list of existing files that will be used to find the highest vercode
907 def manifest_paths(app_dir, flavours):
909 possible_manifests = \
910 [os.path.join(app_dir, 'AndroidManifest.xml'),
911 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
912 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
913 os.path.join(app_dir, 'build.gradle')]
915 for flavour in flavours:
918 possible_manifests.append(
919 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
921 return [path for path in possible_manifests if os.path.isfile(path)]
924 # Retrieve the package name. Returns the name, or None if not found.
925 def fetch_real_name(app_dir, flavours):
926 for path in manifest_paths(app_dir, flavours):
927 if not has_extension(path, 'xml') or not os.path.isfile(path):
929 logging.debug("fetch_real_name: Checking manifest at " + path)
930 xml = parse_xml(path)
931 app = xml.find('application')
932 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
934 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
935 result = retrieve_string_singleline(app_dir, label)
937 result = result.strip()
942 def get_library_references(root_dir):
944 proppath = os.path.join(root_dir, 'project.properties')
945 if not os.path.isfile(proppath):
947 for line in file(proppath):
948 if not line.startswith('android.library.reference.'):
950 path = line.split('=')[1].strip()
951 relpath = os.path.join(root_dir, path)
952 if not os.path.isdir(relpath):
954 logging.debug("Found subproject at %s" % path)
955 libraries.append(path)
959 def ant_subprojects(root_dir):
960 subprojects = get_library_references(root_dir)
961 for subpath in subprojects:
962 subrelpath = os.path.join(root_dir, subpath)
963 for p in get_library_references(subrelpath):
964 relp = os.path.normpath(os.path.join(subpath, p))
965 if relp not in subprojects:
966 subprojects.insert(0, relp)
970 def remove_debuggable_flags(root_dir):
971 # Remove forced debuggable flags
972 logging.debug("Removing debuggable flags from %s" % root_dir)
973 for root, dirs, files in os.walk(root_dir):
974 if 'AndroidManifest.xml' in files:
975 regsub_file(r'android:debuggable="[^"]*"',
977 os.path.join(root, 'AndroidManifest.xml'))
980 # Extract some information from the AndroidManifest.xml at the given path.
981 # Returns (version, vercode, package), any or all of which might be None.
982 # All values returned are strings.
983 def parse_androidmanifests(paths, ignoreversions=None):
986 return (None, None, None)
988 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
989 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
990 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
992 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1000 if not os.path.isfile(path):
1003 logging.debug("Parsing manifest at {0}".format(path))
1004 gradle = has_extension(path, 'gradle')
1007 # Remember package name, may be defined separately from version+vercode
1008 package = max_package
1011 for line in file(path):
1013 matches = psearch_g(line)
1015 package = matches.group(1)
1017 matches = vnsearch_g(line)
1019 version = matches.group(2)
1021 matches = vcsearch_g(line)
1023 vercode = matches.group(1)
1025 xml = parse_xml(path)
1026 if "package" in xml.attrib:
1027 package = xml.attrib["package"].encode('utf-8')
1028 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1029 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1030 base_dir = os.path.dirname(path)
1031 version = retrieve_string_singleline(base_dir, version)
1032 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1033 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1034 if string_is_integer(a):
1037 logging.debug("..got package={0}, version={1}, vercode={2}"
1038 .format(package, version, vercode))
1040 # Always grab the package name and version name in case they are not
1041 # together with the highest version code
1042 if max_package is None and package is not None:
1043 max_package = package
1044 if max_version is None and version is not None:
1045 max_version = version
1047 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1048 if not ignoresearch or not ignoresearch(version):
1049 if version is not None:
1050 max_version = version
1051 if vercode is not None:
1052 max_vercode = vercode
1053 if package is not None:
1054 max_package = package
1056 max_version = "Ignore"
1058 if max_version is None:
1059 max_version = "Unknown"
1061 if max_package and not is_valid_package_name(max_package):
1062 raise FDroidException("Invalid package name {0}".format(max_package))
1064 return (max_version, max_vercode, max_package)
1067 def is_valid_package_name(name):
1068 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1071 class FDroidException(Exception):
1073 def __init__(self, value, detail=None):
1075 self.detail = detail
1077 def get_wikitext(self):
1078 ret = repr(self.value) + "\n"
1082 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1090 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1094 class VCSException(FDroidException):
1098 class BuildException(FDroidException):
1102 # Get the specified source library.
1103 # Returns the path to it. Normally this is the path to be used when referencing
1104 # it, which may be a subdirectory of the actual project. If you want the base
1105 # directory of the project, pass 'basepath=True'.
1106 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1107 raw=False, prepare=True, preponly=False, refresh=True):
1115 name, ref = spec.split('@')
1117 number, name = name.split(':', 1)
1119 name, subdir = name.split('/', 1)
1121 if name not in metadata.srclibs:
1122 raise VCSException('srclib ' + name + ' not found.')
1124 srclib = metadata.srclibs[name]
1126 sdir = os.path.join(srclib_dir, name)
1129 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1130 vcs.srclib = (name, number, sdir)
1132 vcs.gotorevision(ref, refresh)
1139 libdir = os.path.join(sdir, subdir)
1140 elif srclib["Subdir"]:
1141 for subdir in srclib["Subdir"]:
1142 libdir_candidate = os.path.join(sdir, subdir)
1143 if os.path.exists(libdir_candidate):
1144 libdir = libdir_candidate
1150 remove_signing_keys(sdir)
1151 remove_debuggable_flags(sdir)
1155 if srclib["Prepare"]:
1156 cmd = replace_config_vars(srclib["Prepare"], None)
1158 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1159 if p.returncode != 0:
1160 raise BuildException("Error running prepare command for srclib %s"
1166 return (name, number, libdir)
1169 # Prepare the source code for a particular build
1170 # 'vcs' - the appropriate vcs object for the application
1171 # 'app' - the application details from the metadata
1172 # 'build' - the build details from the metadata
1173 # 'build_dir' - the path to the build directory, usually
1175 # 'srclib_dir' - the path to the source libraries directory, usually
1177 # 'extlib_dir' - the path to the external libraries directory, usually
1179 # Returns the (root, srclibpaths) where:
1180 # 'root' is the root directory, which may be the same as 'build_dir' or may
1181 # be a subdirectory of it.
1182 # 'srclibpaths' is information on the srclibs being used
1183 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1185 # Optionally, the actual app source can be in a subdirectory
1187 root_dir = os.path.join(build_dir, build['subdir'])
1189 root_dir = build_dir
1191 # Get a working copy of the right revision
1192 logging.info("Getting source for revision " + build['commit'])
1193 vcs.gotorevision(build['commit'], refresh)
1195 # Initialise submodules if required
1196 if build['submodules']:
1197 logging.info("Initialising submodules")
1198 vcs.initsubmodules()
1200 # Check that a subdir (if we're using one) exists. This has to happen
1201 # after the checkout, since it might not exist elsewhere
1202 if not os.path.exists(root_dir):
1203 raise BuildException('Missing subdir ' + root_dir)
1205 # Run an init command if one is required
1207 cmd = replace_config_vars(build['init'], build)
1208 logging.info("Running 'init' commands in %s" % root_dir)
1210 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1211 if p.returncode != 0:
1212 raise BuildException("Error running init command for %s:%s" %
1213 (app['id'], build['version']), p.output)
1215 # Apply patches if any
1217 logging.info("Applying patches")
1218 for patch in build['patch']:
1219 patch = patch.strip()
1220 logging.info("Applying " + patch)
1221 patch_path = os.path.join('metadata', app['id'], patch)
1222 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1223 if p.returncode != 0:
1224 raise BuildException("Failed to apply patch %s" % patch_path)
1226 # Get required source libraries
1228 if build['srclibs']:
1229 logging.info("Collecting source libraries")
1230 for lib in build['srclibs']:
1231 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1233 for name, number, libpath in srclibpaths:
1234 place_srclib(root_dir, int(number) if number else None, libpath)
1236 basesrclib = vcs.getsrclib()
1237 # If one was used for the main source, add that too.
1239 srclibpaths.append(basesrclib)
1241 # Update the local.properties file
1242 localprops = [os.path.join(build_dir, 'local.properties')]
1244 localprops += [os.path.join(root_dir, 'local.properties')]
1245 for path in localprops:
1247 if os.path.isfile(path):
1248 logging.info("Updating local.properties file at %s" % path)
1254 logging.info("Creating local.properties file at %s" % path)
1255 # Fix old-fashioned 'sdk-location' by copying
1256 # from sdk.dir, if necessary
1257 if build['oldsdkloc']:
1258 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1259 re.S | re.M).group(1)
1260 props += "sdk-location=%s\n" % sdkloc
1262 props += "sdk.dir=%s\n" % config['sdk_path']
1263 props += "sdk-location=%s\n" % config['sdk_path']
1264 if build['ndk_path']:
1266 props += "ndk.dir=%s\n" % build['ndk_path']
1267 props += "ndk-location=%s\n" % build['ndk_path']
1268 # Add java.encoding if necessary
1269 if build['encoding']:
1270 props += "java.encoding=%s\n" % build['encoding']
1276 if build['type'] == 'gradle':
1277 flavours = build['gradle']
1279 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1280 gradlepluginver = None
1282 gradle_dirs = [root_dir]
1284 # Parent dir build.gradle
1285 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1286 if parent_dir.startswith(build_dir):
1287 gradle_dirs.append(parent_dir)
1289 for dir_path in gradle_dirs:
1292 if not os.path.isdir(dir_path):
1294 for filename in os.listdir(dir_path):
1295 if not filename.endswith('.gradle'):
1297 path = os.path.join(dir_path, filename)
1298 if not os.path.isfile(path):
1300 for line in file(path):
1301 match = version_regex.match(line)
1303 gradlepluginver = match.group(1)
1307 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1309 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1310 build['gradlepluginver'] = LooseVersion('0.11')
1313 n = build["target"].split('-')[1]
1314 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1315 r'compileSdkVersion %s' % n,
1316 os.path.join(root_dir, 'build.gradle'))
1318 # Remove forced debuggable flags
1319 remove_debuggable_flags(root_dir)
1321 # Insert version code and number into the manifest if necessary
1322 if build['forceversion']:
1323 logging.info("Changing the version name")
1324 for path in manifest_paths(root_dir, flavours):
1325 if not os.path.isfile(path):
1327 if has_extension(path, 'xml'):
1328 regsub_file(r'android:versionName="[^"]*"',
1329 r'android:versionName="%s"' % build['version'],
1331 elif has_extension(path, 'gradle'):
1332 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1333 r"""\1versionName '%s'""" % build['version'],
1336 if build['forcevercode']:
1337 logging.info("Changing the version code")
1338 for path in manifest_paths(root_dir, flavours):
1339 if not os.path.isfile(path):
1341 if has_extension(path, 'xml'):
1342 regsub_file(r'android:versionCode="[^"]*"',
1343 r'android:versionCode="%s"' % build['vercode'],
1345 elif has_extension(path, 'gradle'):
1346 regsub_file(r'versionCode[ =]+[0-9]+',
1347 r'versionCode %s' % build['vercode'],
1350 # Delete unwanted files
1352 logging.info("Removing specified files")
1353 for part in getpaths(build_dir, build, 'rm'):
1354 dest = os.path.join(build_dir, part)
1355 logging.info("Removing {0}".format(part))
1356 if os.path.lexists(dest):
1357 if os.path.islink(dest):
1358 FDroidPopen(['unlink', dest], output=False)
1360 FDroidPopen(['rm', '-rf', dest], output=False)
1362 logging.info("...but it didn't exist")
1364 remove_signing_keys(build_dir)
1366 # Add required external libraries
1367 if build['extlibs']:
1368 logging.info("Collecting prebuilt libraries")
1369 libsdir = os.path.join(root_dir, 'libs')
1370 if not os.path.exists(libsdir):
1372 for lib in build['extlibs']:
1374 logging.info("...installing extlib {0}".format(lib))
1375 libf = os.path.basename(lib)
1376 libsrc = os.path.join(extlib_dir, lib)
1377 if not os.path.exists(libsrc):
1378 raise BuildException("Missing extlib file {0}".format(libsrc))
1379 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1381 # Run a pre-build command if one is required
1382 if build['prebuild']:
1383 logging.info("Running 'prebuild' commands in %s" % root_dir)
1385 cmd = replace_config_vars(build['prebuild'], build)
1387 # Substitute source library paths into prebuild commands
1388 for name, number, libpath in srclibpaths:
1389 libpath = os.path.relpath(libpath, root_dir)
1390 cmd = cmd.replace('$$' + name + '$$', libpath)
1392 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1393 if p.returncode != 0:
1394 raise BuildException("Error running prebuild command for %s:%s" %
1395 (app['id'], build['version']), p.output)
1397 # Generate (or update) the ant build file, build.xml...
1398 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1399 parms = ['android', 'update', 'lib-project']
1400 lparms = ['android', 'update', 'project']
1403 parms += ['-t', build['target']]
1404 lparms += ['-t', build['target']]
1405 if build['update'] == ['auto']:
1406 update_dirs = ant_subprojects(root_dir) + ['.']
1408 update_dirs = build['update']
1410 for d in update_dirs:
1411 subdir = os.path.join(root_dir, d)
1413 logging.debug("Updating main project")
1414 cmd = parms + ['-p', d]
1416 logging.debug("Updating subproject %s" % d)
1417 cmd = lparms + ['-p', d]
1418 p = SdkToolsPopen(cmd, cwd=root_dir)
1419 # Check to see whether an error was returned without a proper exit
1420 # code (this is the case for the 'no target set or target invalid'
1422 if p.returncode != 0 or p.output.startswith("Error: "):
1423 raise BuildException("Failed to update project at %s" % d, p.output)
1424 # Clean update dirs via ant
1426 logging.info("Cleaning subproject %s" % d)
1427 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1429 return (root_dir, srclibpaths)
1432 # Split and extend via globbing the paths from a field
1433 def getpaths(build_dir, build, field):
1435 for p in build[field]:
1437 full_path = os.path.join(build_dir, p)
1438 full_path = os.path.normpath(full_path)
1439 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1443 def get_mime_type(path):
1445 There are two incompatible versions of the 'magic' module, one
1446 that comes as part of libmagic, which is what Debian includes as
1447 python-magic, then another called python-magic that is a separate
1448 project that wraps libmagic. The second is 'magic' on pypi, so
1449 both need to be supported. Then on platforms where libmagic is
1450 not easily included, e.g. OSX and Windows, fallback to the
1451 built-in 'mimetypes' module so this will work without
1452 libmagic. Hence this function with the following hacks:
1459 ms = magic.open(magic.MIME_TYPE)
1461 result = magic.from_file(path, mime=True)
1462 except AttributeError:
1463 result = ms.file(path)
1464 except UnicodeError:
1465 logging.warn('Found malformed magic number at %s' % path)
1470 result = mimetypes.guess_type(path, strict=False)
1476 # Scan the source code in the given directory (and all subdirectories)
1477 # and return the number of fatal problems encountered
1478 def scan_source(build_dir, root_dir, thisbuild):
1482 # Common known non-free blobs (always lower case):
1484 re.compile(r'.*flurryagent', re.IGNORECASE),
1485 re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1486 re.compile(r'.*google.*analytics', re.IGNORECASE),
1487 re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1488 re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1489 re.compile(r'.*google.*admob', re.IGNORECASE),
1490 re.compile(r'.*google.*play.*services', re.IGNORECASE),
1491 re.compile(r'.*crittercism', re.IGNORECASE),
1492 re.compile(r'.*heyzap', re.IGNORECASE),
1493 re.compile(r'.*jpct.*ae', re.IGNORECASE),
1494 re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1495 re.compile(r'.*bugsense', re.IGNORECASE),
1496 re.compile(r'.*crashlytics', re.IGNORECASE),
1497 re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1498 re.compile(r'.*libspen23', re.IGNORECASE),
1501 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1502 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1504 scanignore_worked = set()
1505 scandelete_worked = set()
1508 for p in scanignore:
1509 if fd.startswith(p):
1510 scanignore_worked.add(p)
1515 for p in scandelete:
1516 if fd.startswith(p):
1517 scandelete_worked.add(p)
1521 def ignoreproblem(what, fd, fp):
1522 logging.info('Ignoring %s at %s' % (what, fd))
1525 def removeproblem(what, fd, fp):
1526 logging.info('Removing %s at %s' % (what, fd))
1530 def warnproblem(what, fd):
1531 logging.warn('Found %s at %s' % (what, fd))
1533 def handleproblem(what, fd, fp):
1535 return ignoreproblem(what, fd, fp)
1537 return removeproblem(what, fd, fp)
1538 logging.error('Found %s at %s' % (what, fd))
1541 # Iterate through all files in the source code
1542 for r, d, f in os.walk(build_dir, topdown=True):
1544 # It's topdown, so checking the basename is enough
1545 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1551 # Path (relative) to the file
1552 fp = os.path.join(r, curfile)
1553 fd = fp[len(build_dir) + 1:]
1555 mime = get_mime_type(fp)
1557 if mime == 'application/x-sharedlib':
1558 count += handleproblem('shared library', fd, fp)
1560 elif mime == 'application/x-archive':
1561 count += handleproblem('static library', fd, fp)
1563 elif mime == 'application/x-executable' or mime == 'application/x-mach-binary':
1564 count += handleproblem('binary executable', fd, fp)
1566 elif mime == 'application/x-java-applet':
1567 count += handleproblem('Java compiled class', fd, fp)
1572 'application/java-archive',
1573 'application/octet-stream',
1576 if has_extension(fp, 'apk'):
1577 removeproblem('APK file', fd, fp)
1579 elif has_extension(fp, 'jar'):
1581 if any(suspect.match(curfile) for suspect in usual_suspects):
1582 count += handleproblem('usual supect', fd, fp)
1584 warnproblem('JAR file', fd)
1586 elif has_extension(fp, 'zip'):
1587 warnproblem('ZIP file', fd)
1590 warnproblem('unknown compressed or binary file', fd)
1592 elif has_extension(fp, 'java'):
1593 if not os.path.isfile(fp):
1595 for line in file(fp):
1596 if 'DexClassLoader' in line:
1597 count += handleproblem('DexClassLoader', fd, fp)
1600 elif has_extension(fp, 'gradle'):
1601 if not os.path.isfile(fp):
1603 for i, line in enumerate(file(fp)):
1605 if any(suspect.match(line) for suspect in usual_suspects):
1606 count += handleproblem('usual suspect at line %d' % i, fd, fp)
1609 for p in scanignore:
1610 if p not in scanignore_worked:
1611 logging.error('Unused scanignore path: %s' % p)
1614 for p in scandelete:
1615 if p not in scandelete_worked:
1616 logging.error('Unused scandelete path: %s' % p)
1619 # Presence of a jni directory without buildjni=yes might
1620 # indicate a problem (if it's not a problem, explicitly use
1621 # buildjni=no to bypass this check)
1622 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1623 not thisbuild['buildjni']):
1624 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1633 self.path = os.path.join('stats', 'known_apks.txt')
1635 if os.path.isfile(self.path):
1636 for line in file(self.path):
1637 t = line.rstrip().split(' ')
1639 self.apks[t[0]] = (t[1], None)
1641 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1642 self.changed = False
1644 def writeifchanged(self):
1646 if not os.path.exists('stats'):
1648 f = open(self.path, 'w')
1650 for apk, app in self.apks.iteritems():
1652 line = apk + ' ' + appid
1654 line += ' ' + time.strftime('%Y-%m-%d', added)
1656 for line in sorted(lst):
1657 f.write(line + '\n')
1660 # Record an apk (if it's new, otherwise does nothing)
1661 # Returns the date it was added.
1662 def recordapk(self, apk, app):
1663 if apk not in self.apks:
1664 self.apks[apk] = (app, time.gmtime(time.time()))
1666 _, added = self.apks[apk]
1669 # Look up information - given the 'apkname', returns (app id, date added/None).
1670 # Or returns None for an unknown apk.
1671 def getapp(self, apkname):
1672 if apkname in self.apks:
1673 return self.apks[apkname]
1676 # Get the most recent 'num' apps added to the repo, as a list of package ids
1677 # with the most recent first.
1678 def getlatest(self, num):
1680 for apk, app in self.apks.iteritems():
1684 if apps[appid] > added:
1688 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1689 lst = [app for app, _ in sortedapps]
1694 def isApkDebuggable(apkfile, config):
1695 """Returns True if the given apk file is debuggable
1697 :param apkfile: full path to the apk to check"""
1699 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1701 if p.returncode != 0:
1702 logging.critical("Failed to get apk manifest information")
1704 for line in p.output.splitlines():
1705 if 'android:debuggable' in line and not line.endswith('0x0'):
1710 class AsynchronousFileReader(threading.Thread):
1713 Helper class to implement asynchronous reading of a file
1714 in a separate thread. Pushes read lines on a queue to
1715 be consumed in another thread.
1718 def __init__(self, fd, queue):
1719 assert isinstance(queue, Queue.Queue)
1720 assert callable(fd.readline)
1721 threading.Thread.__init__(self)
1726 '''The body of the tread: read lines and put them on the queue.'''
1727 for line in iter(self._fd.readline, ''):
1728 self._queue.put(line)
1731 '''Check whether there is no more content to expect.'''
1732 return not self.is_alive() and self._queue.empty()
1740 def SdkToolsPopen(commands, cwd=None, output=True):
1742 if cmd not in config:
1743 config[cmd] = find_sdk_tools_cmd(commands[0])
1744 return FDroidPopen([config[cmd]] + commands[1:],
1745 cwd=cwd, output=output)
1748 def FDroidPopen(commands, cwd=None, output=True):
1750 Run a command and capture the possibly huge output.
1752 :param commands: command and argument list like in subprocess.Popen
1753 :param cwd: optionally specifies a working directory
1754 :returns: A PopenResult.
1760 cwd = os.path.normpath(cwd)
1761 logging.debug("Directory: %s" % cwd)
1762 logging.debug("> %s" % ' '.join(commands))
1764 result = PopenResult()
1767 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1768 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1770 raise BuildException("OSError while trying to execute " +
1771 ' '.join(commands) + ': ' + str(e))
1773 stdout_queue = Queue.Queue()
1774 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1775 stdout_reader.start()
1777 # Check the queue for output (until there is no more to get)
1778 while not stdout_reader.eof():
1779 while not stdout_queue.empty():
1780 line = stdout_queue.get()
1781 if output and options.verbose:
1782 # Output directly to console
1783 sys.stderr.write(line)
1785 result.output += line
1789 result.returncode = p.wait()
1793 def remove_signing_keys(build_dir):
1794 comment = re.compile(r'[ ]*//')
1795 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1797 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1798 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1799 re.compile(r'.*variant\.outputFile = .*'),
1800 re.compile(r'.*output\.outputFile = .*'),
1801 re.compile(r'.*\.readLine\(.*'),
1803 for root, dirs, files in os.walk(build_dir):
1804 if 'build.gradle' in files:
1805 path = os.path.join(root, 'build.gradle')
1807 with open(path, "r") as o:
1808 lines = o.readlines()
1814 with open(path, "w") as o:
1815 while i < len(lines):
1818 while line.endswith('\\\n'):
1819 line = line.rstrip('\\\n') + lines[i]
1822 if comment.match(line):
1826 opened += line.count('{')
1827 opened -= line.count('}')
1830 if signing_configs.match(line):
1835 if any(s.match(line) for s in line_matches):
1843 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1846 'project.properties',
1848 'default.properties',
1849 'ant.properties', ]:
1850 if propfile in files:
1851 path = os.path.join(root, propfile)
1853 with open(path, "r") as o:
1854 lines = o.readlines()
1858 with open(path, "w") as o:
1860 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1867 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1870 def reset_env_path():
1871 global env, orig_path
1872 env['PATH'] = orig_path
1875 def add_to_env_path(path):
1877 paths = env['PATH'].split(os.pathsep)
1881 env['PATH'] = os.pathsep.join(paths)
1884 def replace_config_vars(cmd, build):
1886 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1887 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1888 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1889 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1890 if build is not None:
1891 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1892 cmd = cmd.replace('$$VERSION$$', build['version'])
1893 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1897 def place_srclib(root_dir, number, libpath):
1900 relpath = os.path.relpath(libpath, root_dir)
1901 proppath = os.path.join(root_dir, 'project.properties')
1904 if os.path.isfile(proppath):
1905 with open(proppath, "r") as o:
1906 lines = o.readlines()
1908 with open(proppath, "w") as o:
1911 if line.startswith('android.library.reference.%d=' % number):
1912 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1917 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1920 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1921 """Verify that two apks are the same
1923 One of the inputs is signed, the other is unsigned. The signature metadata
1924 is transferred from the signed to the unsigned apk, and then jarsigner is
1925 used to verify that the signature from the signed apk is also varlid for
1927 :param signed_apk: Path to a signed apk file
1928 :param unsigned_apk: Path to an unsigned apk file expected to match it
1929 :param tmp_dir: Path to directory for temporary files
1930 :returns: None if the verification is successful, otherwise a string
1931 describing what went wrong.
1933 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1934 with ZipFile(signed_apk) as signed_apk_as_zip:
1935 meta_inf_files = ['META-INF/MANIFEST.MF']
1936 for f in signed_apk_as_zip.namelist():
1937 if sigfile.match(f):
1938 meta_inf_files.append(f)
1939 if len(meta_inf_files) < 3:
1940 return "Signature files missing from {0}".format(signed_apk)
1941 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1942 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1943 for meta_inf_file in meta_inf_files:
1944 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1946 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1947 logging.info("...NOT verified - {0}".format(signed_apk))
1948 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1949 logging.info("...successfully verified")
1953 def compare_apks(apk1, apk2, tmp_dir):
1956 Returns None if the apk content is the same (apart from the signing key),
1957 otherwise a string describing what's different, or what went wrong when
1958 trying to do the comparison.
1961 badchars = re.compile('''[/ :;'"]''')
1962 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1963 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1964 for d in [apk1dir, apk2dir]:
1965 if os.path.exists(d):
1968 os.mkdir(os.path.join(d, 'jar-xf'))
1970 if subprocess.call(['jar', 'xf',
1971 os.path.abspath(apk1)],
1972 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1973 return("Failed to unpack " + apk1)
1974 if subprocess.call(['jar', 'xf',
1975 os.path.abspath(apk2)],
1976 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1977 return("Failed to unpack " + apk2)
1979 # try to find apktool in the path, if it hasn't been manually configed
1980 if 'apktool' not in config:
1981 tmp = find_command('apktool')
1983 config['apktool'] = tmp
1984 if 'apktool' in config:
1985 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1987 return("Failed to unpack " + apk1)
1988 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1990 return("Failed to unpack " + apk2)
1992 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1993 lines = p.output.splitlines()
1994 if len(lines) != 1 or 'META-INF' not in lines[0]:
1995 meld = find_command('meld')
1996 if meld is not None:
1997 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1998 return("Unexpected diff output - " + p.output)
2000 # since everything verifies, delete the comparison to keep cruft down
2001 shutil.rmtree(apk1dir)
2002 shutil.rmtree(apk2dir)
2004 # If we get here, it seems like they're the same!
2008 def find_command(command):
2009 '''find the full path of a command, or None if it can't be found in the PATH'''
2012 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2014 fpath, fname = os.path.split(command)
2019 for path in os.environ["PATH"].split(os.pathsep):
2020 path = path.strip('"')
2021 exe_file = os.path.join(path, command)
2022 if is_exe(exe_file):
2029 '''generate a random password for when generating keys'''
2030 h = hashlib.sha256()
2031 h.update(os.urandom(16)) # salt
2032 h.update(bytes(socket.getfqdn()))
2033 return h.digest().encode('base64').strip()
2036 def genkeystore(localconfig):
2037 '''Generate a new key with random passwords and add it to new keystore'''
2038 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2039 keystoredir = os.path.dirname(localconfig['keystore'])
2040 if keystoredir is None or keystoredir == '':
2041 keystoredir = os.path.join(os.getcwd(), keystoredir)
2042 if not os.path.exists(keystoredir):
2043 os.makedirs(keystoredir, mode=0o700)
2045 write_password_file("keystorepass", localconfig['keystorepass'])
2046 write_password_file("keypass", localconfig['keypass'])
2047 p = FDroidPopen(['keytool', '-genkey',
2048 '-keystore', localconfig['keystore'],
2049 '-alias', localconfig['repo_keyalias'],
2050 '-keyalg', 'RSA', '-keysize', '4096',
2051 '-sigalg', 'SHA256withRSA',
2052 '-validity', '10000',
2053 '-storepass:file', config['keystorepassfile'],
2054 '-keypass:file', config['keypassfile'],
2055 '-dname', localconfig['keydname']])
2056 # TODO keypass should be sent via stdin
2057 if p.returncode != 0:
2058 raise BuildException("Failed to generate key", p.output)
2059 os.chmod(localconfig['keystore'], 0o0600)
2060 # now show the lovely key that was just generated
2061 p = FDroidPopen(['keytool', '-list', '-v',
2062 '-keystore', localconfig['keystore'],
2063 '-alias', localconfig['repo_keyalias'],
2064 '-storepass:file', config['keystorepassfile']])
2065 logging.info(p.output.strip() + '\n\n')
2068 def write_to_config(thisconfig, key, value=None):
2069 '''write a key/value to the local config.py'''
2071 origkey = key + '_orig'
2072 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2073 with open('config.py', 'r') as f:
2075 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2076 repl = '\n' + key + ' = "' + value + '"'
2077 data = re.sub(pattern, repl, data)
2078 # if this key is not in the file, append it
2079 if not re.match('\s*' + key + '\s*=\s*"', data):
2081 # make sure the file ends with a carraige return
2082 if not re.match('\n$', data):
2084 with open('config.py', 'w') as f:
2088 def parse_xml(path):
2089 return XMLElementTree.parse(path).getroot()
2092 def string_is_integer(string):
2100 def download_file(url, local_filename=None, dldir='tmp'):
2101 filename = url.split('/')[-1]
2102 if local_filename is None:
2103 local_filename = os.path.join(dldir, filename)
2104 # the stream=True parameter keeps memory usage low
2105 r = requests.get(url, stream=True)
2106 with open(local_filename, 'wb') as f:
2107 for chunk in r.iter_content(chunk_size=1024):
2108 if chunk: # filter out keep-alive new chunks
2111 return local_filename