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/>.
36 import xml.etree.ElementTree as XMLElementTree
38 from distutils.version import LooseVersion
39 from zipfile import ZipFile
43 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
52 'sdk_path': "$ANDROID_HOME",
55 'r10e': "$ANDROID_NDK"
57 'build_tools': "22.0.1",
61 'sync_from_local_copy_dir': False,
62 'make_current_version_link': True,
63 'current_version_name_source': 'Name',
64 'update_stats': False,
68 'stats_to_carbon': False,
70 'build_server_always': False,
71 'keystore': 'keystore.jks',
72 'smartcardoptions': [],
78 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
79 'repo_name': "My First FDroid Repo Demo",
80 'repo_icon': "fdroid-icon.png",
81 'repo_description': '''
82 This is a repository of apps to be used with FDroid. Applications in this
83 repository are either official binaries built by the original application
84 developers, or are binaries built from source by the admin of f-droid.org
85 using the tools on https://gitlab.com/u/fdroid.
91 def fill_config_defaults(thisconfig):
92 for k, v in default_config.items():
93 if k not in thisconfig:
96 # Expand paths (~users and $vars)
97 def expand_path(path):
101 path = os.path.expanduser(path)
102 path = os.path.expandvars(path)
107 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
112 thisconfig[k + '_orig'] = v
114 for k in ['ndk_paths']:
120 thisconfig[k][k2] = exp
121 thisconfig[k][k2 + '_orig'] = v
124 def read_config(opts, config_file='config.py'):
125 """Read the repository config
127 The config is read from config_file, which is in the current directory when
128 any of the repo management commands are used.
130 global config, options, env, orig_path
132 if config is not None:
134 if not os.path.isfile(config_file):
135 logging.critical("Missing config file - is this a repo directory?")
142 logging.debug("Reading %s" % config_file)
143 execfile(config_file, config)
145 # smartcardoptions must be a list since its command line args for Popen
146 if 'smartcardoptions' in config:
147 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
148 elif 'keystore' in config and config['keystore'] == 'NONE':
149 # keystore='NONE' means use smartcard, these are required defaults
150 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
151 'SunPKCS11-OpenSC', '-providerClass',
152 'sun.security.pkcs11.SunPKCS11',
153 '-providerArg', 'opensc-fdroid.cfg']
155 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
156 st = os.stat(config_file)
157 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
158 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
160 fill_config_defaults(config)
162 # There is no standard, so just set up the most common environment
165 orig_path = env['PATH']
166 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
167 env[n] = config['sdk_path']
169 for k in ["keystorepass", "keypass"]:
171 write_password_file(k)
173 for k in ["repo_description", "archive_description"]:
175 config[k] = clean_description(config[k])
177 if 'serverwebroot' in config:
178 if isinstance(config['serverwebroot'], basestring):
179 roots = [config['serverwebroot']]
180 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
181 roots = config['serverwebroot']
183 raise TypeError('only accepts strings, lists, and tuples')
185 for rootstr in roots:
186 # since this is used with rsync, where trailing slashes have
187 # meaning, ensure there is always a trailing slash
188 if rootstr[-1] != '/':
190 rootlist.append(rootstr.replace('//', '/'))
191 config['serverwebroot'] = rootlist
196 def get_ndk_path(version):
198 version = 'r10e' # latest
199 paths = config['ndk_paths']
200 if version not in paths:
202 return paths[version] or ''
205 def find_sdk_tools_cmd(cmd):
206 '''find a working path to a tool from the Android SDK'''
209 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
210 # try to find a working path to this command, in all the recent possible paths
211 if 'build_tools' in config:
212 build_tools = os.path.join(config['sdk_path'], 'build-tools')
213 # if 'build_tools' was manually set and exists, check only that one
214 configed_build_tools = os.path.join(build_tools, config['build_tools'])
215 if os.path.exists(configed_build_tools):
216 tooldirs.append(configed_build_tools)
218 # no configed version, so hunt known paths for it
219 for f in sorted(os.listdir(build_tools), reverse=True):
220 if os.path.isdir(os.path.join(build_tools, f)):
221 tooldirs.append(os.path.join(build_tools, f))
222 tooldirs.append(build_tools)
223 sdk_tools = os.path.join(config['sdk_path'], 'tools')
224 if os.path.exists(sdk_tools):
225 tooldirs.append(sdk_tools)
226 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
227 if os.path.exists(sdk_platform_tools):
228 tooldirs.append(sdk_platform_tools)
229 tooldirs.append('/usr/bin')
231 if os.path.isfile(os.path.join(d, cmd)):
232 return os.path.join(d, cmd)
233 # did not find the command, exit with error message
234 ensure_build_tools_exists(config)
237 def test_sdk_exists(thisconfig):
238 if 'sdk_path' not in thisconfig:
239 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
242 logging.error("'sdk_path' not set in config.py!")
244 if thisconfig['sdk_path'] == default_config['sdk_path']:
245 logging.error('No Android SDK found!')
246 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
247 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
249 if not os.path.exists(thisconfig['sdk_path']):
250 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
252 if not os.path.isdir(thisconfig['sdk_path']):
253 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
255 for d in ['build-tools', 'platform-tools', 'tools']:
256 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
257 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
258 thisconfig['sdk_path'], d))
263 def ensure_build_tools_exists(thisconfig):
264 if not test_sdk_exists(thisconfig):
266 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
267 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
268 if not os.path.isdir(versioned_build_tools):
269 logging.critical('Android Build Tools path "'
270 + versioned_build_tools + '" does not exist!')
274 def write_password_file(pwtype, password=None):
276 writes out passwords to a protected file instead of passing passwords as
277 command line argments
279 filename = '.fdroid.' + pwtype + '.txt'
280 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
282 os.write(fd, config[pwtype])
284 os.write(fd, password)
286 config[pwtype + 'file'] = filename
289 # Given the arguments in the form of multiple appid:[vc] strings, this returns
290 # a dictionary with the set of vercodes specified for each package.
291 def read_pkg_args(args, allow_vercodes=False):
298 if allow_vercodes and ':' in p:
299 package, vercode = p.split(':')
301 package, vercode = p, None
302 if package not in vercodes:
303 vercodes[package] = [vercode] if vercode else []
305 elif vercode and vercode not in vercodes[package]:
306 vercodes[package] += [vercode] if vercode else []
311 # On top of what read_pkg_args does, this returns the whole app metadata, but
312 # limiting the builds list to the builds matching the vercodes specified.
313 def read_app_args(args, allapps, allow_vercodes=False):
315 vercodes = read_pkg_args(args, allow_vercodes)
321 for appid, app in allapps.iteritems():
322 if appid in vercodes:
325 if len(apps) != len(vercodes):
328 logging.critical("No such package: %s" % p)
329 raise FDroidException("Found invalid app ids in arguments")
331 raise FDroidException("No packages specified")
334 for appid, app in apps.iteritems():
338 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
339 if len(app['builds']) != len(vercodes[appid]):
341 allvcs = [b['vercode'] for b in app['builds']]
342 for v in vercodes[appid]:
344 logging.critical("No such vercode %s for app %s" % (v, appid))
347 raise FDroidException("Found invalid vercodes for some apps")
352 def has_extension(filename, extension):
353 name, ext = os.path.splitext(filename)
354 ext = ext.lower()[1:]
355 return ext == extension
360 def clean_description(description):
361 'Remove unneeded newlines and spaces from a block of description text'
363 # this is split up by paragraph to make removing the newlines easier
364 for paragraph in re.split(r'\n\n', description):
365 paragraph = re.sub('\r', '', paragraph)
366 paragraph = re.sub('\n', ' ', paragraph)
367 paragraph = re.sub(' {2,}', ' ', paragraph)
368 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
369 returnstring += paragraph + '\n\n'
370 return returnstring.rstrip('\n')
373 def apknameinfo(filename):
375 filename = os.path.basename(filename)
376 if apk_regex is None:
377 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
378 m = apk_regex.match(filename)
380 result = (m.group(1), m.group(2))
381 except AttributeError:
382 raise FDroidException("Invalid apk name: %s" % filename)
386 def getapkname(app, build):
387 return "%s_%s.apk" % (app['id'], build['vercode'])
390 def getsrcname(app, build):
391 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
398 return app['Auto Name']
403 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
406 def getvcs(vcstype, remote, local):
408 return vcs_git(remote, local)
409 if vcstype == 'git-svn':
410 return vcs_gitsvn(remote, local)
412 return vcs_hg(remote, local)
414 return vcs_bzr(remote, local)
415 if vcstype == 'srclib':
416 if local != os.path.join('build', 'srclib', remote):
417 raise VCSException("Error: srclib paths are hard-coded!")
418 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
420 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
421 raise VCSException("Invalid vcs type " + vcstype)
424 def getsrclibvcs(name):
425 if name not in metadata.srclibs:
426 raise VCSException("Missing srclib " + name)
427 return metadata.srclibs[name]['Repo Type']
432 def __init__(self, remote, local):
434 # svn, git-svn and bzr may require auth
436 if self.repotype() in ('git-svn', 'bzr'):
438 if self.repotype == 'git-svn':
439 raise VCSException("Authentication is not supported for git-svn")
440 self.username, remote = remote.split('@')
441 if ':' not in self.username:
442 raise VCSException("Password required with username")
443 self.username, self.password = self.username.split(':')
447 self.clone_failed = False
448 self.refreshed = False
454 # Take the local repository to a clean version of the given revision, which
455 # is specificed in the VCS's native format. Beforehand, the repository can
456 # be dirty, or even non-existent. If the repository does already exist
457 # locally, it will be updated from the origin, but only once in the
458 # lifetime of the vcs object.
459 # None is acceptable for 'rev' if you know you are cloning a clean copy of
460 # the repo - otherwise it must specify a valid revision.
461 def gotorevision(self, rev):
463 if self.clone_failed:
464 raise VCSException("Downloading the repository already failed once, not trying again.")
466 # The .fdroidvcs-id file for a repo tells us what VCS type
467 # and remote that directory was created from, allowing us to drop it
468 # automatically if either of those things changes.
469 fdpath = os.path.join(self.local, '..',
470 '.fdroidvcs-' + os.path.basename(self.local))
471 cdata = self.repotype() + ' ' + self.remote
474 if os.path.exists(self.local):
475 if os.path.exists(fdpath):
476 with open(fdpath, 'r') as f:
477 fsdata = f.read().strip()
482 logging.info("Repository details for %s changed - deleting" % (
486 logging.info("Repository details for %s missing - deleting" % (
489 shutil.rmtree(self.local)
494 self.gotorevisionx(rev)
495 except FDroidException, e:
498 # If necessary, write the .fdroidvcs file.
499 if writeback and not self.clone_failed:
500 with open(fdpath, 'w') as f:
506 # Derived classes need to implement this. It's called once basic checking
507 # has been performend.
508 def gotorevisionx(self, rev):
509 raise VCSException("This VCS type doesn't define gotorevisionx")
511 # Initialise and update submodules
512 def initsubmodules(self):
513 raise VCSException('Submodules not supported for this vcs type')
515 # Get a list of all known tags
517 if not self._gettags:
518 raise VCSException('gettags not supported for this vcs type')
520 for tag in self._gettags():
521 if re.match('[-A-Za-z0-9_. ]+$', tag):
525 def latesttags(self, tags, number):
526 """Get the most recent tags in a given list.
528 :param tags: a list of tags
529 :param number: the number to return
530 :returns: A list containing the most recent tags in the provided
531 list, up to the maximum number given.
533 raise VCSException('latesttags not supported for this vcs type')
535 # Get current commit reference (hash, revision, etc)
537 raise VCSException('getref not supported for this vcs type')
539 # Returns the srclib (name, path) used in setting up the current
550 # If the local directory exists, but is somehow not a git repository, git
551 # will traverse up the directory tree until it finds one that is (i.e.
552 # fdroidserver) and then we'll proceed to destroy it! This is called as
555 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
556 result = p.output.rstrip()
557 if not result.endswith(self.local):
558 raise VCSException('Repository mismatch')
560 def gotorevisionx(self, rev):
561 if not os.path.exists(self.local):
563 p = FDroidPopen(['git', 'clone', self.remote, self.local])
564 if p.returncode != 0:
565 self.clone_failed = True
566 raise VCSException("Git clone failed", p.output)
570 # Discard any working tree changes
571 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
572 'git', 'reset', '--hard'], cwd=self.local, output=False)
573 if p.returncode != 0:
574 raise VCSException("Git reset failed", p.output)
575 # Remove untracked files now, in case they're tracked in the target
576 # revision (it happens!)
577 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
578 'git', 'clean', '-dffx'], cwd=self.local, output=False)
579 if p.returncode != 0:
580 raise VCSException("Git clean failed", p.output)
581 if not self.refreshed:
582 # Get latest commits and tags from remote
583 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
584 if p.returncode != 0:
585 raise VCSException("Git fetch failed", p.output)
586 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
587 if p.returncode != 0:
588 raise VCSException("Git fetch failed", p.output)
589 # Recreate origin/HEAD as git clone would do it, in case it disappeared
590 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
591 if p.returncode != 0:
592 lines = p.output.splitlines()
593 if 'Multiple remote HEAD branches' not in lines[0]:
594 raise VCSException("Git remote set-head failed", p.output)
595 branch = lines[1].split(' ')[-1]
596 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
597 if p2.returncode != 0:
598 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
599 self.refreshed = True
600 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
601 # a github repo. Most of the time this is the same as origin/master.
602 rev = rev or 'origin/HEAD'
603 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
604 if p.returncode != 0:
605 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
606 # Get rid of any uncontrolled files left behind
607 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
608 if p.returncode != 0:
609 raise VCSException("Git clean failed", p.output)
611 def initsubmodules(self):
613 submfile = os.path.join(self.local, '.gitmodules')
614 if not os.path.isfile(submfile):
615 raise VCSException("No git submodules available")
617 # fix submodules not accessible without an account and public key auth
618 with open(submfile, 'r') as f:
619 lines = f.readlines()
620 with open(submfile, 'w') as f:
622 if 'git@github.com' in line:
623 line = line.replace('git@github.com:', 'https://github.com/')
626 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
627 if p.returncode != 0:
628 raise VCSException("Git submodule sync failed", p.output)
629 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
630 if p.returncode != 0:
631 raise VCSException("Git submodule update failed", p.output)
635 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
636 return p.output.splitlines()
638 def latesttags(self, tags, number):
643 ['git', 'show', '--format=format:%ct', '-s', tag],
644 cwd=self.local, output=False)
645 # Timestamp is on the last line. For a normal tag, it's the only
646 # line, but for annotated tags, the rest of the info precedes it.
647 ts = int(p.output.splitlines()[-1])
650 for _, t in sorted(tl)[-number:]:
655 class vcs_gitsvn(vcs):
660 # If the local directory exists, but is somehow not a git repository, git
661 # will traverse up the directory tree until it finds one that is (i.e.
662 # fdroidserver) and then we'll proceed to destory it! This is called as
665 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
666 result = p.output.rstrip()
667 if not result.endswith(self.local):
668 raise VCSException('Repository mismatch')
670 def gotorevisionx(self, rev):
671 if not os.path.exists(self.local):
673 gitsvn_args = ['git', 'svn', 'clone']
674 if ';' in self.remote:
675 remote_split = self.remote.split(';')
676 for i in remote_split[1:]:
677 if i.startswith('trunk='):
678 gitsvn_args.extend(['-T', i[6:]])
679 elif i.startswith('tags='):
680 gitsvn_args.extend(['-t', i[5:]])
681 elif i.startswith('branches='):
682 gitsvn_args.extend(['-b', i[9:]])
683 gitsvn_args.extend([remote_split[0], self.local])
684 p = FDroidPopen(gitsvn_args, output=False)
685 if p.returncode != 0:
686 self.clone_failed = True
687 raise VCSException("Git svn clone failed", p.output)
689 gitsvn_args.extend([self.remote, self.local])
690 p = FDroidPopen(gitsvn_args, output=False)
691 if p.returncode != 0:
692 self.clone_failed = True
693 raise VCSException("Git svn clone failed", p.output)
697 # Discard any working tree changes
698 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
699 if p.returncode != 0:
700 raise VCSException("Git reset failed", p.output)
701 # Remove untracked files now, in case they're tracked in the target
702 # revision (it happens!)
703 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
704 if p.returncode != 0:
705 raise VCSException("Git clean failed", p.output)
706 if not self.refreshed:
707 # Get new commits, branches and tags from repo
708 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
709 if p.returncode != 0:
710 raise VCSException("Git svn fetch failed")
711 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
712 if p.returncode != 0:
713 raise VCSException("Git svn rebase failed", p.output)
714 self.refreshed = True
716 rev = rev or 'master'
718 nospaces_rev = rev.replace(' ', '%20')
719 # Try finding a svn tag
720 for treeish in ['origin/', '']:
721 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
722 if p.returncode == 0:
724 if p.returncode != 0:
725 # No tag found, normal svn rev translation
726 # Translate svn rev into git format
727 rev_split = rev.split('/')
730 for treeish in ['origin/', '']:
731 if len(rev_split) > 1:
732 treeish += rev_split[0]
733 svn_rev = rev_split[1]
736 # if no branch is specified, then assume trunk (i.e. 'master' branch):
740 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
742 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
743 git_rev = p.output.rstrip()
745 if p.returncode == 0 and git_rev:
748 if p.returncode != 0 or not git_rev:
749 # Try a plain git checkout as a last resort
750 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
751 if p.returncode != 0:
752 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
754 # Check out the git rev equivalent to the svn rev
755 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
756 if p.returncode != 0:
757 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
759 # Get rid of any uncontrolled files left behind
760 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
761 if p.returncode != 0:
762 raise VCSException("Git clean failed", p.output)
766 for treeish in ['origin/', '']:
767 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
773 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
774 if p.returncode != 0:
776 return p.output.strip()
784 def gotorevisionx(self, rev):
785 if not os.path.exists(self.local):
786 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
787 if p.returncode != 0:
788 self.clone_failed = True
789 raise VCSException("Hg clone failed", p.output)
791 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
792 if p.returncode != 0:
793 raise VCSException("Hg status failed", p.output)
794 for line in p.output.splitlines():
795 if not line.startswith('? '):
796 raise VCSException("Unexpected output from hg status -uS: " + line)
797 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
798 if not self.refreshed:
799 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
800 if p.returncode != 0:
801 raise VCSException("Hg pull failed", p.output)
802 self.refreshed = True
804 rev = rev or 'default'
807 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
808 if p.returncode != 0:
809 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
810 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
811 # Also delete untracked files, we have to enable purge extension for that:
812 if "'purge' is provided by the following extension" in p.output:
813 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
814 myfile.write("\n[extensions]\nhgext.purge=\n")
815 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
816 if p.returncode != 0:
817 raise VCSException("HG purge failed", p.output)
818 elif p.returncode != 0:
819 raise VCSException("HG purge failed", p.output)
822 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
823 return p.output.splitlines()[1:]
831 def gotorevisionx(self, rev):
832 if not os.path.exists(self.local):
833 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
834 if p.returncode != 0:
835 self.clone_failed = True
836 raise VCSException("Bzr branch failed", p.output)
838 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
839 if p.returncode != 0:
840 raise VCSException("Bzr revert failed", p.output)
841 if not self.refreshed:
842 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
843 if p.returncode != 0:
844 raise VCSException("Bzr update failed", p.output)
845 self.refreshed = True
847 revargs = list(['-r', rev] if rev else [])
848 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
849 if p.returncode != 0:
850 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
853 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
854 return [tag.split(' ')[0].strip() for tag in
855 p.output.splitlines()]
858 def unescape_string(string):
859 if string[0] == '"' and string[-1] == '"':
862 return string.replace("\\'", "'")
865 def retrieve_string(app_dir, string, xmlfiles=None):
870 os.path.join(app_dir, 'res'),
871 os.path.join(app_dir, 'src', 'main', 'res'),
873 for r, d, f in os.walk(res_dir):
874 if os.path.basename(r) == 'values':
875 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
877 if not string.startswith('@string/'):
878 return unescape_string(string)
880 name = string[len('@string/'):]
882 for path in xmlfiles:
883 if not os.path.isfile(path):
885 xml = parse_xml(path)
886 element = xml.find('string[@name="' + name + '"]')
887 if element is not None and element.text is not None:
888 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
893 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
894 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
897 # Return list of existing files that will be used to find the highest vercode
898 def manifest_paths(app_dir, flavours):
900 possible_manifests = \
901 [os.path.join(app_dir, 'AndroidManifest.xml'),
902 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
903 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
904 os.path.join(app_dir, 'build.gradle')]
906 for flavour in flavours:
909 possible_manifests.append(
910 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
912 return [path for path in possible_manifests if os.path.isfile(path)]
915 # Retrieve the package name. Returns the name, or None if not found.
916 def fetch_real_name(app_dir, flavours):
917 for path in manifest_paths(app_dir, flavours):
918 if not has_extension(path, 'xml') or not os.path.isfile(path):
920 logging.debug("fetch_real_name: Checking manifest at " + path)
921 xml = parse_xml(path)
922 app = xml.find('application')
923 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
925 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
926 result = retrieve_string_singleline(app_dir, label)
928 result = result.strip()
933 def get_library_references(root_dir):
935 proppath = os.path.join(root_dir, 'project.properties')
936 if not os.path.isfile(proppath):
938 for line in file(proppath):
939 if not line.startswith('android.library.reference.'):
941 path = line.split('=')[1].strip()
942 relpath = os.path.join(root_dir, path)
943 if not os.path.isdir(relpath):
945 logging.debug("Found subproject at %s" % path)
946 libraries.append(path)
950 def ant_subprojects(root_dir):
951 subprojects = get_library_references(root_dir)
952 for subpath in subprojects:
953 subrelpath = os.path.join(root_dir, subpath)
954 for p in get_library_references(subrelpath):
955 relp = os.path.normpath(os.path.join(subpath, p))
956 if relp not in subprojects:
957 subprojects.insert(0, relp)
961 def remove_debuggable_flags(root_dir):
962 # Remove forced debuggable flags
963 logging.debug("Removing debuggable flags from %s" % root_dir)
964 for root, dirs, files in os.walk(root_dir):
965 if 'AndroidManifest.xml' in files:
966 path = os.path.join(root, 'AndroidManifest.xml')
967 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
968 if p.returncode != 0:
969 raise BuildException("Failed to remove debuggable flags of %s" % path)
972 # Extract some information from the AndroidManifest.xml at the given path.
973 # Returns (version, vercode, package), any or all of which might be None.
974 # All values returned are strings.
975 def parse_androidmanifests(paths, ignoreversions=None):
978 return (None, None, None)
980 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
981 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
982 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
984 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
992 if not os.path.isfile(path):
995 logging.debug("Parsing manifest at {0}".format(path))
996 gradle = has_extension(path, 'gradle')
999 # Remember package name, may be defined separately from version+vercode
1000 package = max_package
1003 for line in file(path):
1005 matches = psearch_g(line)
1007 package = matches.group(1)
1009 matches = vnsearch_g(line)
1011 version = matches.group(2)
1013 matches = vcsearch_g(line)
1015 vercode = matches.group(1)
1017 xml = parse_xml(path)
1018 if "package" in xml.attrib:
1019 package = xml.attrib["package"].encode('utf-8')
1020 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1021 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1022 base_dir = os.path.dirname(path)
1023 version = retrieve_string_singleline(base_dir, version)
1024 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1025 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1026 if string_is_integer(a):
1029 logging.debug("..got package={0}, version={1}, vercode={2}"
1030 .format(package, version, vercode))
1032 # Always grab the package name and version name in case they are not
1033 # together with the highest version code
1034 if max_package is None and package is not None:
1035 max_package = package
1036 if max_version is None and version is not None:
1037 max_version = version
1039 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1040 if not ignoresearch or not ignoresearch(version):
1041 if version is not None:
1042 max_version = version
1043 if vercode is not None:
1044 max_vercode = vercode
1045 if package is not None:
1046 max_package = package
1048 max_version = "Ignore"
1050 if max_version is None:
1051 max_version = "Unknown"
1053 if max_package and not is_valid_package_name(max_package):
1054 raise FDroidException("Invalid package name {0}".format(max_package))
1056 return (max_version, max_vercode, max_package)
1059 def is_valid_package_name(name):
1060 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1063 class FDroidException(Exception):
1065 def __init__(self, value, detail=None):
1067 self.detail = detail
1069 def get_wikitext(self):
1070 ret = repr(self.value) + "\n"
1074 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1082 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1086 class VCSException(FDroidException):
1090 class BuildException(FDroidException):
1094 # Get the specified source library.
1095 # Returns the path to it. Normally this is the path to be used when referencing
1096 # it, which may be a subdirectory of the actual project. If you want the base
1097 # directory of the project, pass 'basepath=True'.
1098 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1099 raw=False, prepare=True, preponly=False):
1107 name, ref = spec.split('@')
1109 number, name = name.split(':', 1)
1111 name, subdir = name.split('/', 1)
1113 if name not in metadata.srclibs:
1114 raise VCSException('srclib ' + name + ' not found.')
1116 srclib = metadata.srclibs[name]
1118 sdir = os.path.join(srclib_dir, name)
1121 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1122 vcs.srclib = (name, number, sdir)
1124 vcs.gotorevision(ref)
1131 libdir = os.path.join(sdir, subdir)
1132 elif srclib["Subdir"]:
1133 for subdir in srclib["Subdir"]:
1134 libdir_candidate = os.path.join(sdir, subdir)
1135 if os.path.exists(libdir_candidate):
1136 libdir = libdir_candidate
1142 remove_signing_keys(sdir)
1143 remove_debuggable_flags(sdir)
1147 if srclib["Prepare"]:
1148 cmd = replace_config_vars(srclib["Prepare"], None)
1150 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1151 if p.returncode != 0:
1152 raise BuildException("Error running prepare command for srclib %s"
1158 return (name, number, libdir)
1161 # Prepare the source code for a particular build
1162 # 'vcs' - the appropriate vcs object for the application
1163 # 'app' - the application details from the metadata
1164 # 'build' - the build details from the metadata
1165 # 'build_dir' - the path to the build directory, usually
1167 # 'srclib_dir' - the path to the source libraries directory, usually
1169 # 'extlib_dir' - the path to the external libraries directory, usually
1171 # Returns the (root, srclibpaths) where:
1172 # 'root' is the root directory, which may be the same as 'build_dir' or may
1173 # be a subdirectory of it.
1174 # 'srclibpaths' is information on the srclibs being used
1175 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1177 # Optionally, the actual app source can be in a subdirectory
1179 root_dir = os.path.join(build_dir, build['subdir'])
1181 root_dir = build_dir
1183 # Get a working copy of the right revision
1184 logging.info("Getting source for revision " + build['commit'])
1185 vcs.gotorevision(build['commit'])
1187 # Initialise submodules if required
1188 if build['submodules']:
1189 logging.info("Initialising submodules")
1190 vcs.initsubmodules()
1192 # Check that a subdir (if we're using one) exists. This has to happen
1193 # after the checkout, since it might not exist elsewhere
1194 if not os.path.exists(root_dir):
1195 raise BuildException('Missing subdir ' + root_dir)
1197 # Run an init command if one is required
1199 cmd = replace_config_vars(build['init'], build)
1200 logging.info("Running 'init' commands in %s" % root_dir)
1202 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1203 if p.returncode != 0:
1204 raise BuildException("Error running init command for %s:%s" %
1205 (app['id'], build['version']), p.output)
1207 # Apply patches if any
1209 logging.info("Applying patches")
1210 for patch in build['patch']:
1211 patch = patch.strip()
1212 logging.info("Applying " + patch)
1213 patch_path = os.path.join('metadata', app['id'], patch)
1214 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1215 if p.returncode != 0:
1216 raise BuildException("Failed to apply patch %s" % patch_path)
1218 # Get required source libraries
1220 if build['srclibs']:
1221 logging.info("Collecting source libraries")
1222 for lib in build['srclibs']:
1223 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver))
1225 for name, number, libpath in srclibpaths:
1226 place_srclib(root_dir, int(number) if number else None, libpath)
1228 basesrclib = vcs.getsrclib()
1229 # If one was used for the main source, add that too.
1231 srclibpaths.append(basesrclib)
1233 # Update the local.properties file
1234 localprops = [os.path.join(build_dir, 'local.properties')]
1236 localprops += [os.path.join(root_dir, 'local.properties')]
1237 for path in localprops:
1239 if os.path.isfile(path):
1240 logging.info("Updating local.properties file at %s" % path)
1246 logging.info("Creating local.properties file at %s" % path)
1247 # Fix old-fashioned 'sdk-location' by copying
1248 # from sdk.dir, if necessary
1249 if build['oldsdkloc']:
1250 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1251 re.S | re.M).group(1)
1252 props += "sdk-location=%s\n" % sdkloc
1254 props += "sdk.dir=%s\n" % config['sdk_path']
1255 props += "sdk-location=%s\n" % config['sdk_path']
1256 if build['ndk_path']:
1258 props += "ndk.dir=%s\n" % build['ndk_path']
1259 props += "ndk-location=%s\n" % build['ndk_path']
1260 # Add java.encoding if necessary
1261 if build['encoding']:
1262 props += "java.encoding=%s\n" % build['encoding']
1268 if build['type'] == 'gradle':
1269 flavours = build['gradle']
1271 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1272 gradlepluginver = None
1274 gradle_dirs = [root_dir]
1276 # Parent dir build.gradle
1277 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1278 if parent_dir.startswith(build_dir):
1279 gradle_dirs.append(parent_dir)
1281 for dir_path in gradle_dirs:
1284 if not os.path.isdir(dir_path):
1286 for filename in os.listdir(dir_path):
1287 if not filename.endswith('.gradle'):
1289 path = os.path.join(dir_path, filename)
1290 if not os.path.isfile(path):
1292 for line in file(path):
1293 match = version_regex.match(line)
1295 gradlepluginver = match.group(1)
1299 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1301 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1302 build['gradlepluginver'] = LooseVersion('0.11')
1305 n = build["target"].split('-')[1]
1306 FDroidPopen(['sed', '-i',
1307 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1308 'build.gradle'], cwd=root_dir, output=False)
1310 # Remove forced debuggable flags
1311 remove_debuggable_flags(root_dir)
1313 # Insert version code and number into the manifest if necessary
1314 if build['forceversion']:
1315 logging.info("Changing the version name")
1316 for path in manifest_paths(root_dir, flavours):
1317 if not os.path.isfile(path):
1319 if has_extension(path, 'xml'):
1320 p = FDroidPopen(['sed', '-i',
1321 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1322 path], output=False)
1323 if p.returncode != 0:
1324 raise BuildException("Failed to amend manifest")
1325 elif has_extension(path, 'gradle'):
1326 p = FDroidPopen(['sed', '-i',
1327 's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1328 path], output=False)
1329 if p.returncode != 0:
1330 raise BuildException("Failed to amend build.gradle")
1331 if build['forcevercode']:
1332 logging.info("Changing the version code")
1333 for path in manifest_paths(root_dir, flavours):
1334 if not os.path.isfile(path):
1336 if has_extension(path, 'xml'):
1337 p = FDroidPopen(['sed', '-i',
1338 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1339 path], output=False)
1340 if p.returncode != 0:
1341 raise BuildException("Failed to amend manifest")
1342 elif has_extension(path, 'gradle'):
1343 p = FDroidPopen(['sed', '-i',
1344 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1345 path], output=False)
1346 if p.returncode != 0:
1347 raise BuildException("Failed to amend build.gradle")
1349 # Delete unwanted files
1351 logging.info("Removing specified files")
1352 for part in getpaths(build_dir, build, 'rm'):
1353 dest = os.path.join(build_dir, part)
1354 logging.info("Removing {0}".format(part))
1355 if os.path.lexists(dest):
1356 if os.path.islink(dest):
1357 FDroidPopen(['unlink', dest], output=False)
1359 FDroidPopen(['rm', '-rf', dest], output=False)
1361 logging.info("...but it didn't exist")
1363 remove_signing_keys(build_dir)
1365 # Add required external libraries
1366 if build['extlibs']:
1367 logging.info("Collecting prebuilt libraries")
1368 libsdir = os.path.join(root_dir, 'libs')
1369 if not os.path.exists(libsdir):
1371 for lib in build['extlibs']:
1373 logging.info("...installing extlib {0}".format(lib))
1374 libf = os.path.basename(lib)
1375 libsrc = os.path.join(extlib_dir, lib)
1376 if not os.path.exists(libsrc):
1377 raise BuildException("Missing extlib file {0}".format(libsrc))
1378 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1380 # Run a pre-build command if one is required
1381 if build['prebuild']:
1382 logging.info("Running 'prebuild' commands in %s" % root_dir)
1384 cmd = replace_config_vars(build['prebuild'], build)
1386 # Substitute source library paths into prebuild commands
1387 for name, number, libpath in srclibpaths:
1388 libpath = os.path.relpath(libpath, root_dir)
1389 cmd = cmd.replace('$$' + name + '$$', libpath)
1391 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1392 if p.returncode != 0:
1393 raise BuildException("Error running prebuild command for %s:%s" %
1394 (app['id'], build['version']), p.output)
1396 # Generate (or update) the ant build file, build.xml...
1397 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1398 parms = ['android', 'update', 'lib-project']
1399 lparms = ['android', 'update', 'project']
1402 parms += ['-t', build['target']]
1403 lparms += ['-t', build['target']]
1404 if build['update'] == ['auto']:
1405 update_dirs = ant_subprojects(root_dir) + ['.']
1407 update_dirs = build['update']
1409 for d in update_dirs:
1410 subdir = os.path.join(root_dir, d)
1412 logging.debug("Updating main project")
1413 cmd = parms + ['-p', d]
1415 logging.debug("Updating subproject %s" % d)
1416 cmd = lparms + ['-p', d]
1417 p = SdkToolsPopen(cmd, cwd=root_dir)
1418 # Check to see whether an error was returned without a proper exit
1419 # code (this is the case for the 'no target set or target invalid'
1421 if p.returncode != 0 or p.output.startswith("Error: "):
1422 raise BuildException("Failed to update project at %s" % d, p.output)
1423 # Clean update dirs via ant
1425 logging.info("Cleaning subproject %s" % d)
1426 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1428 return (root_dir, srclibpaths)
1431 # Split and extend via globbing the paths from a field
1432 def getpaths(build_dir, build, field):
1434 for p in build[field]:
1436 full_path = os.path.join(build_dir, p)
1437 full_path = os.path.normpath(full_path)
1438 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1442 # Scan the source code in the given directory (and all subdirectories)
1443 # and return the number of fatal problems encountered
1444 def scan_source(build_dir, root_dir, thisbuild):
1448 # Common known non-free blobs (always lower case):
1450 re.compile(r'.*flurryagent', re.IGNORECASE),
1451 re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1452 re.compile(r'.*google.*analytics', re.IGNORECASE),
1453 re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1454 re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1455 re.compile(r'.*google.*admob', re.IGNORECASE),
1456 re.compile(r'.*google.*play.*services', re.IGNORECASE),
1457 re.compile(r'.*crittercism', re.IGNORECASE),
1458 re.compile(r'.*heyzap', re.IGNORECASE),
1459 re.compile(r'.*jpct.*ae', re.IGNORECASE),
1460 re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1461 re.compile(r'.*bugsense', re.IGNORECASE),
1462 re.compile(r'.*crashlytics', re.IGNORECASE),
1463 re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1464 re.compile(r'.*libspen23', re.IGNORECASE),
1467 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1468 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1470 scanignore_worked = set()
1471 scandelete_worked = set()
1474 ms = magic.open(magic.MIME_TYPE)
1476 except AttributeError:
1480 for p in scanignore:
1481 if fd.startswith(p):
1482 scanignore_worked.add(p)
1487 for p in scandelete:
1488 if fd.startswith(p):
1489 scandelete_worked.add(p)
1493 def ignoreproblem(what, fd, fp):
1494 logging.info('Ignoring %s at %s' % (what, fd))
1497 def removeproblem(what, fd, fp):
1498 logging.info('Removing %s at %s' % (what, fd))
1502 def warnproblem(what, fd):
1503 logging.warn('Found %s at %s' % (what, fd))
1505 def handleproblem(what, fd, fp):
1507 return ignoreproblem(what, fd, fp)
1509 return removeproblem(what, fd, fp)
1510 logging.error('Found %s at %s' % (what, fd))
1513 # Iterate through all files in the source code
1514 for r, d, f in os.walk(build_dir, topdown=True):
1516 # It's topdown, so checking the basename is enough
1517 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1523 # Path (relative) to the file
1524 fp = os.path.join(r, curfile)
1525 fd = fp[len(build_dir) + 1:]
1528 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1529 except UnicodeError:
1530 warnproblem('malformed magic number', fd)
1532 if mime == 'application/x-sharedlib':
1533 count += handleproblem('shared library', fd, fp)
1535 elif mime == 'application/x-archive':
1536 count += handleproblem('static library', fd, fp)
1538 elif mime == 'application/x-executable':
1539 count += handleproblem('binary executable', fd, fp)
1541 elif mime == 'application/x-java-applet':
1542 count += handleproblem('Java compiled class', fd, fp)
1547 'application/java-archive',
1548 'application/octet-stream',
1551 if has_extension(fp, 'apk'):
1552 removeproblem('APK file', fd, fp)
1554 elif has_extension(fp, 'jar'):
1556 if any(suspect.match(curfile) for suspect in usual_suspects):
1557 count += handleproblem('usual supect', fd, fp)
1559 warnproblem('JAR file', fd)
1561 elif has_extension(fp, 'zip'):
1562 warnproblem('ZIP file', fd)
1565 warnproblem('unknown compressed or binary file', fd)
1567 elif has_extension(fp, 'java'):
1568 if not os.path.isfile(fp):
1570 for line in file(fp):
1571 if 'DexClassLoader' in line:
1572 count += handleproblem('DexClassLoader', fd, fp)
1575 elif has_extension(fp, 'gradle'):
1576 if not os.path.isfile(fp):
1578 for i, line in enumerate(file(fp)):
1579 if any(suspect.match(line) for suspect in usual_suspects):
1580 count += handleproblem('usual suspect at line %d' % i, fd, fp)
1585 for p in scanignore:
1586 if p not in scanignore_worked:
1587 logging.error('Unused scanignore path: %s' % p)
1590 for p in scandelete:
1591 if p not in scandelete_worked:
1592 logging.error('Unused scandelete path: %s' % p)
1595 # Presence of a jni directory without buildjni=yes might
1596 # indicate a problem (if it's not a problem, explicitly use
1597 # buildjni=no to bypass this check)
1598 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1599 not thisbuild['buildjni']):
1600 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1609 self.path = os.path.join('stats', 'known_apks.txt')
1611 if os.path.isfile(self.path):
1612 for line in file(self.path):
1613 t = line.rstrip().split(' ')
1615 self.apks[t[0]] = (t[1], None)
1617 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1618 self.changed = False
1620 def writeifchanged(self):
1622 if not os.path.exists('stats'):
1624 f = open(self.path, 'w')
1626 for apk, app in self.apks.iteritems():
1628 line = apk + ' ' + appid
1630 line += ' ' + time.strftime('%Y-%m-%d', added)
1632 for line in sorted(lst):
1633 f.write(line + '\n')
1636 # Record an apk (if it's new, otherwise does nothing)
1637 # Returns the date it was added.
1638 def recordapk(self, apk, app):
1639 if apk not in self.apks:
1640 self.apks[apk] = (app, time.gmtime(time.time()))
1642 _, added = self.apks[apk]
1645 # Look up information - given the 'apkname', returns (app id, date added/None).
1646 # Or returns None for an unknown apk.
1647 def getapp(self, apkname):
1648 if apkname in self.apks:
1649 return self.apks[apkname]
1652 # Get the most recent 'num' apps added to the repo, as a list of package ids
1653 # with the most recent first.
1654 def getlatest(self, num):
1656 for apk, app in self.apks.iteritems():
1660 if apps[appid] > added:
1664 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1665 lst = [app for app, _ in sortedapps]
1670 def isApkDebuggable(apkfile, config):
1671 """Returns True if the given apk file is debuggable
1673 :param apkfile: full path to the apk to check"""
1675 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1677 if p.returncode != 0:
1678 logging.critical("Failed to get apk manifest information")
1680 for line in p.output.splitlines():
1681 if 'android:debuggable' in line and not line.endswith('0x0'):
1686 class AsynchronousFileReader(threading.Thread):
1689 Helper class to implement asynchronous reading of a file
1690 in a separate thread. Pushes read lines on a queue to
1691 be consumed in another thread.
1694 def __init__(self, fd, queue):
1695 assert isinstance(queue, Queue.Queue)
1696 assert callable(fd.readline)
1697 threading.Thread.__init__(self)
1702 '''The body of the tread: read lines and put them on the queue.'''
1703 for line in iter(self._fd.readline, ''):
1704 self._queue.put(line)
1707 '''Check whether there is no more content to expect.'''
1708 return not self.is_alive() and self._queue.empty()
1716 def SdkToolsPopen(commands, cwd=None, output=True):
1718 if cmd not in config:
1719 config[cmd] = find_sdk_tools_cmd(commands[0])
1720 return FDroidPopen([config[cmd]] + commands[1:],
1721 cwd=cwd, output=output)
1724 def FDroidPopen(commands, cwd=None, output=True):
1726 Run a command and capture the possibly huge output.
1728 :param commands: command and argument list like in subprocess.Popen
1729 :param cwd: optionally specifies a working directory
1730 :returns: A PopenResult.
1736 cwd = os.path.normpath(cwd)
1737 logging.debug("Directory: %s" % cwd)
1738 logging.debug("> %s" % ' '.join(commands))
1740 result = PopenResult()
1743 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1744 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1746 raise BuildException("OSError while trying to execute " +
1747 ' '.join(commands) + ': ' + str(e))
1749 stdout_queue = Queue.Queue()
1750 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1751 stdout_reader.start()
1753 # Check the queue for output (until there is no more to get)
1754 while not stdout_reader.eof():
1755 while not stdout_queue.empty():
1756 line = stdout_queue.get()
1757 if output and options.verbose:
1758 # Output directly to console
1759 sys.stderr.write(line)
1761 result.output += line
1765 result.returncode = p.wait()
1769 def remove_signing_keys(build_dir):
1770 comment = re.compile(r'[ ]*//')
1771 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1773 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1774 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1775 re.compile(r'.*variant\.outputFile = .*'),
1776 re.compile(r'.*output\.outputFile = .*'),
1777 re.compile(r'.*\.readLine\(.*'),
1779 for root, dirs, files in os.walk(build_dir):
1780 if 'build.gradle' in files:
1781 path = os.path.join(root, 'build.gradle')
1783 with open(path, "r") as o:
1784 lines = o.readlines()
1790 with open(path, "w") as o:
1791 while i < len(lines):
1794 while line.endswith('\\\n'):
1795 line = line.rstrip('\\\n') + lines[i]
1798 if comment.match(line):
1802 opened += line.count('{')
1803 opened -= line.count('}')
1806 if signing_configs.match(line):
1811 if any(s.match(line) for s in line_matches):
1819 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1822 'project.properties',
1824 'default.properties',
1825 'ant.properties', ]:
1826 if propfile in files:
1827 path = os.path.join(root, propfile)
1829 with open(path, "r") as o:
1830 lines = o.readlines()
1834 with open(path, "w") as o:
1836 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1843 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1846 def reset_env_path():
1847 global env, orig_path
1848 env['PATH'] = orig_path
1851 def add_to_env_path(path):
1853 paths = env['PATH'].split(os.pathsep)
1857 env['PATH'] = os.pathsep.join(paths)
1860 def replace_config_vars(cmd, build):
1862 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1863 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1864 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1865 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1866 if build is not None:
1867 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1868 cmd = cmd.replace('$$VERSION$$', build['version'])
1869 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1873 def place_srclib(root_dir, number, libpath):
1876 relpath = os.path.relpath(libpath, root_dir)
1877 proppath = os.path.join(root_dir, 'project.properties')
1880 if os.path.isfile(proppath):
1881 with open(proppath, "r") as o:
1882 lines = o.readlines()
1884 with open(proppath, "w") as o:
1887 if line.startswith('android.library.reference.%d=' % number):
1888 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1893 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1896 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1897 """Verify that two apks are the same
1899 One of the inputs is signed, the other is unsigned. The signature metadata
1900 is transferred from the signed to the unsigned apk, and then jarsigner is
1901 used to verify that the signature from the signed apk is also varlid for
1903 :param signed_apk: Path to a signed apk file
1904 :param unsigned_apk: Path to an unsigned apk file expected to match it
1905 :param tmp_dir: Path to directory for temporary files
1906 :returns: None if the verification is successful, otherwise a string
1907 describing what went wrong.
1909 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1910 with ZipFile(signed_apk) as signed_apk_as_zip:
1911 meta_inf_files = ['META-INF/MANIFEST.MF']
1912 for f in signed_apk_as_zip.namelist():
1913 if sigfile.match(f):
1914 meta_inf_files.append(f)
1915 if len(meta_inf_files) < 3:
1916 return "Signature files missing from {0}".format(signed_apk)
1917 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1918 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1919 for meta_inf_file in meta_inf_files:
1920 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1922 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1923 logging.info("...NOT verified - {0}".format(signed_apk))
1924 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1925 logging.info("...successfully verified")
1929 def compare_apks(apk1, apk2, tmp_dir):
1932 Returns None if the apk content is the same (apart from the signing key),
1933 otherwise a string describing what's different, or what went wrong when
1934 trying to do the comparison.
1937 badchars = re.compile('''[/ :;'"]''')
1938 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1939 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1940 for d in [apk1dir, apk2dir]:
1941 if os.path.exists(d):
1944 os.mkdir(os.path.join(d, 'jar-xf'))
1946 if subprocess.call(['jar', 'xf',
1947 os.path.abspath(apk1)],
1948 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1949 return("Failed to unpack " + apk1)
1950 if subprocess.call(['jar', 'xf',
1951 os.path.abspath(apk2)],
1952 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1953 return("Failed to unpack " + apk2)
1955 # try to find apktool in the path, if it hasn't been manually configed
1956 if 'apktool' not in config:
1957 tmp = find_command('apktool')
1959 config['apktool'] = tmp
1960 if 'apktool' in config:
1961 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1963 return("Failed to unpack " + apk1)
1964 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1966 return("Failed to unpack " + apk2)
1968 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1969 lines = p.output.splitlines()
1970 if len(lines) != 1 or 'META-INF' not in lines[0]:
1971 meld = find_command('meld')
1972 if meld is not None:
1973 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1974 return("Unexpected diff output - " + p.output)
1976 # since everything verifies, delete the comparison to keep cruft down
1977 shutil.rmtree(apk1dir)
1978 shutil.rmtree(apk2dir)
1980 # If we get here, it seems like they're the same!
1984 def find_command(command):
1985 '''find the full path of a command, or None if it can't be found in the PATH'''
1988 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1990 fpath, fname = os.path.split(command)
1995 for path in os.environ["PATH"].split(os.pathsep):
1996 path = path.strip('"')
1997 exe_file = os.path.join(path, command)
1998 if is_exe(exe_file):
2005 '''generate a random password for when generating keys'''
2006 h = hashlib.sha256()
2007 h.update(os.urandom(16)) # salt
2008 h.update(bytes(socket.getfqdn()))
2009 return h.digest().encode('base64').strip()
2012 def genkeystore(localconfig):
2013 '''Generate a new key with random passwords and add it to new keystore'''
2014 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2015 keystoredir = os.path.dirname(localconfig['keystore'])
2016 if keystoredir is None or keystoredir == '':
2017 keystoredir = os.path.join(os.getcwd(), keystoredir)
2018 if not os.path.exists(keystoredir):
2019 os.makedirs(keystoredir, mode=0o700)
2021 write_password_file("keystorepass", localconfig['keystorepass'])
2022 write_password_file("keypass", localconfig['keypass'])
2023 p = FDroidPopen(['keytool', '-genkey',
2024 '-keystore', localconfig['keystore'],
2025 '-alias', localconfig['repo_keyalias'],
2026 '-keyalg', 'RSA', '-keysize', '4096',
2027 '-sigalg', 'SHA256withRSA',
2028 '-validity', '10000',
2029 '-storepass:file', config['keystorepassfile'],
2030 '-keypass:file', config['keypassfile'],
2031 '-dname', localconfig['keydname']])
2032 # TODO keypass should be sent via stdin
2033 os.chmod(localconfig['keystore'], 0o0600)
2034 if p.returncode != 0:
2035 raise BuildException("Failed to generate key", p.output)
2036 # now show the lovely key that was just generated
2037 p = FDroidPopen(['keytool', '-list', '-v',
2038 '-keystore', localconfig['keystore'],
2039 '-alias', localconfig['repo_keyalias'],
2040 '-storepass:file', config['keystorepassfile']])
2041 logging.info(p.output.strip() + '\n\n')
2044 def write_to_config(thisconfig, key, value=None):
2045 '''write a key/value to the local config.py'''
2047 origkey = key + '_orig'
2048 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2049 with open('config.py', 'r') as f:
2051 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2052 repl = '\n' + key + ' = "' + value + '"'
2053 data = re.sub(pattern, repl, data)
2054 # if this key is not in the file, append it
2055 if not re.match('\s*' + key + '\s*=\s*"', data):
2057 # make sure the file ends with a carraige return
2058 if not re.match('\n$', data):
2060 with open('config.py', 'w') as f:
2064 def parse_xml(path):
2065 return XMLElementTree.parse(path).getroot()
2068 def string_is_integer(string):
2076 def download_file(url, local_filename=None, dldir='tmp'):
2077 filename = url.split('/')[-1]
2078 if local_filename is None:
2079 local_filename = os.path.join(dldir, filename)
2080 # the stream=True parameter keeps memory usage low
2081 r = requests.get(url, stream=True)
2082 with open(local_filename, 'wb') as f:
2083 for chunk in r.iter_content(chunk_size=1024):
2084 if chunk: # filter out keep-alive new chunks
2087 return local_filename