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 read_config(opts, config_file='config.py'):
124 """Read the repository config
126 The config is read from config_file, which is in the current directory when
127 any of the repo management commands are used.
129 global config, options, env, orig_path
131 if config is not None:
133 if not os.path.isfile(config_file):
134 logging.critical("Missing config file - is this a repo directory?")
141 logging.debug("Reading %s" % config_file)
142 execfile(config_file, config)
144 # smartcardoptions must be a list since its command line args for Popen
145 if 'smartcardoptions' in config:
146 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
147 elif 'keystore' in config and config['keystore'] == 'NONE':
148 # keystore='NONE' means use smartcard, these are required defaults
149 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
150 'SunPKCS11-OpenSC', '-providerClass',
151 'sun.security.pkcs11.SunPKCS11',
152 '-providerArg', 'opensc-fdroid.cfg']
154 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
155 st = os.stat(config_file)
156 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
157 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
159 fill_config_defaults(config)
161 # There is no standard, so just set up the most common environment
164 orig_path = env['PATH']
165 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
166 env[n] = config['sdk_path']
168 for k in ["keystorepass", "keypass"]:
170 write_password_file(k)
172 for k in ["repo_description", "archive_description"]:
174 config[k] = clean_description(config[k])
176 if 'serverwebroot' in config:
177 if isinstance(config['serverwebroot'], basestring):
178 roots = [config['serverwebroot']]
179 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
180 roots = config['serverwebroot']
182 raise TypeError('only accepts strings, lists, and tuples')
184 for rootstr in roots:
185 # since this is used with rsync, where trailing slashes have
186 # meaning, ensure there is always a trailing slash
187 if rootstr[-1] != '/':
189 rootlist.append(rootstr.replace('//', '/'))
190 config['serverwebroot'] = rootlist
195 def get_ndk_path(version):
197 version = 'r10e' # latest
198 paths = config['ndk_paths']
199 if version not in paths:
201 return paths[version] or ''
204 def find_sdk_tools_cmd(cmd):
205 '''find a working path to a tool from the Android SDK'''
208 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
209 # try to find a working path to this command, in all the recent possible paths
210 if 'build_tools' in config:
211 build_tools = os.path.join(config['sdk_path'], 'build-tools')
212 # if 'build_tools' was manually set and exists, check only that one
213 configed_build_tools = os.path.join(build_tools, config['build_tools'])
214 if os.path.exists(configed_build_tools):
215 tooldirs.append(configed_build_tools)
217 # no configed version, so hunt known paths for it
218 for f in sorted(os.listdir(build_tools), reverse=True):
219 if os.path.isdir(os.path.join(build_tools, f)):
220 tooldirs.append(os.path.join(build_tools, f))
221 tooldirs.append(build_tools)
222 sdk_tools = os.path.join(config['sdk_path'], 'tools')
223 if os.path.exists(sdk_tools):
224 tooldirs.append(sdk_tools)
225 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
226 if os.path.exists(sdk_platform_tools):
227 tooldirs.append(sdk_platform_tools)
228 tooldirs.append('/usr/bin')
230 if os.path.isfile(os.path.join(d, cmd)):
231 return os.path.join(d, cmd)
232 # did not find the command, exit with error message
233 ensure_build_tools_exists(config)
236 def test_sdk_exists(thisconfig):
237 if 'sdk_path' not in thisconfig:
238 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
241 logging.error("'sdk_path' not set in config.py!")
243 if thisconfig['sdk_path'] == default_config['sdk_path']:
244 logging.error('No Android SDK found!')
245 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
246 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
248 if not os.path.exists(thisconfig['sdk_path']):
249 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
251 if not os.path.isdir(thisconfig['sdk_path']):
252 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
254 for d in ['build-tools', 'platform-tools', 'tools']:
255 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
256 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
257 thisconfig['sdk_path'], d))
262 def ensure_build_tools_exists(thisconfig):
263 if not test_sdk_exists(thisconfig):
265 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
266 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
267 if not os.path.isdir(versioned_build_tools):
268 logging.critical('Android Build Tools path "'
269 + versioned_build_tools + '" does not exist!')
273 def write_password_file(pwtype, password=None):
275 writes out passwords to a protected file instead of passing passwords as
276 command line argments
278 filename = '.fdroid.' + pwtype + '.txt'
279 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
281 os.write(fd, config[pwtype])
283 os.write(fd, password)
285 config[pwtype + 'file'] = filename
288 # Given the arguments in the form of multiple appid:[vc] strings, this returns
289 # a dictionary with the set of vercodes specified for each package.
290 def read_pkg_args(args, allow_vercodes=False):
297 if allow_vercodes and ':' in p:
298 package, vercode = p.split(':')
300 package, vercode = p, None
301 if package not in vercodes:
302 vercodes[package] = [vercode] if vercode else []
304 elif vercode and vercode not in vercodes[package]:
305 vercodes[package] += [vercode] if vercode else []
310 # On top of what read_pkg_args does, this returns the whole app metadata, but
311 # limiting the builds list to the builds matching the vercodes specified.
312 def read_app_args(args, allapps, allow_vercodes=False):
314 vercodes = read_pkg_args(args, allow_vercodes)
320 for appid, app in allapps.iteritems():
321 if appid in vercodes:
324 if len(apps) != len(vercodes):
327 logging.critical("No such package: %s" % p)
328 raise FDroidException("Found invalid app ids in arguments")
330 raise FDroidException("No packages specified")
333 for appid, app in apps.iteritems():
337 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
338 if len(app['builds']) != len(vercodes[appid]):
340 allvcs = [b['vercode'] for b in app['builds']]
341 for v in vercodes[appid]:
343 logging.critical("No such vercode %s for app %s" % (v, appid))
346 raise FDroidException("Found invalid vercodes for some apps")
351 def has_extension(filename, extension):
352 name, ext = os.path.splitext(filename)
353 ext = ext.lower()[1:]
354 return ext == extension
359 def clean_description(description):
360 'Remove unneeded newlines and spaces from a block of description text'
362 # this is split up by paragraph to make removing the newlines easier
363 for paragraph in re.split(r'\n\n', description):
364 paragraph = re.sub('\r', '', paragraph)
365 paragraph = re.sub('\n', ' ', paragraph)
366 paragraph = re.sub(' {2,}', ' ', paragraph)
367 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
368 returnstring += paragraph + '\n\n'
369 return returnstring.rstrip('\n')
372 def apknameinfo(filename):
374 filename = os.path.basename(filename)
375 if apk_regex is None:
376 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
377 m = apk_regex.match(filename)
379 result = (m.group(1), m.group(2))
380 except AttributeError:
381 raise FDroidException("Invalid apk name: %s" % filename)
385 def getapkname(app, build):
386 return "%s_%s.apk" % (app['id'], build['vercode'])
389 def getsrcname(app, build):
390 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
397 return app['Auto Name']
402 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
405 def getvcs(vcstype, remote, local):
407 return vcs_git(remote, local)
408 if vcstype == 'git-svn':
409 return vcs_gitsvn(remote, local)
411 return vcs_hg(remote, local)
413 return vcs_bzr(remote, local)
414 if vcstype == 'srclib':
415 if local != os.path.join('build', 'srclib', remote):
416 raise VCSException("Error: srclib paths are hard-coded!")
417 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
419 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
420 raise VCSException("Invalid vcs type " + vcstype)
423 def getsrclibvcs(name):
424 if name not in metadata.srclibs:
425 raise VCSException("Missing srclib " + name)
426 return metadata.srclibs[name]['Repo Type']
431 def __init__(self, remote, local):
433 # svn, git-svn and bzr may require auth
435 if self.repotype() in ('git-svn', 'bzr'):
437 if self.repotype == 'git-svn':
438 raise VCSException("Authentication is not supported for git-svn")
439 self.username, remote = remote.split('@')
440 if ':' not in self.username:
441 raise VCSException("Password required with username")
442 self.username, self.password = self.username.split(':')
446 self.clone_failed = False
447 self.refreshed = False
453 # Take the local repository to a clean version of the given revision, which
454 # is specificed in the VCS's native format. Beforehand, the repository can
455 # be dirty, or even non-existent. If the repository does already exist
456 # locally, it will be updated from the origin, but only once in the
457 # lifetime of the vcs object.
458 # None is acceptable for 'rev' if you know you are cloning a clean copy of
459 # the repo - otherwise it must specify a valid revision.
460 def gotorevision(self, rev, refresh=True):
462 if self.clone_failed:
463 raise VCSException("Downloading the repository already failed once, not trying again.")
465 # The .fdroidvcs-id file for a repo tells us what VCS type
466 # and remote that directory was created from, allowing us to drop it
467 # automatically if either of those things changes.
468 fdpath = os.path.join(self.local, '..',
469 '.fdroidvcs-' + os.path.basename(self.local))
470 cdata = self.repotype() + ' ' + self.remote
473 if os.path.exists(self.local):
474 if os.path.exists(fdpath):
475 with open(fdpath, 'r') as f:
476 fsdata = f.read().strip()
481 logging.info("Repository details for %s changed - deleting" % (
485 logging.info("Repository details for %s missing - deleting" % (
488 shutil.rmtree(self.local)
492 self.refreshed = True
495 self.gotorevisionx(rev)
496 except FDroidException, e:
499 # If necessary, write the .fdroidvcs file.
500 if writeback and not self.clone_failed:
501 with open(fdpath, 'w') as f:
507 # Derived classes need to implement this. It's called once basic checking
508 # has been performend.
509 def gotorevisionx(self, rev):
510 raise VCSException("This VCS type doesn't define gotorevisionx")
512 # Initialise and update submodules
513 def initsubmodules(self):
514 raise VCSException('Submodules not supported for this vcs type')
516 # Get a list of all known tags
518 if not self._gettags:
519 raise VCSException('gettags not supported for this vcs type')
521 for tag in self._gettags():
522 if re.match('[-A-Za-z0-9_. ]+$', tag):
526 def latesttags(self, tags, number):
527 """Get the most recent tags in a given list.
529 :param tags: a list of tags
530 :param number: the number to return
531 :returns: A list containing the most recent tags in the provided
532 list, up to the maximum number given.
534 raise VCSException('latesttags not supported for this vcs type')
536 # Get current commit reference (hash, revision, etc)
538 raise VCSException('getref not supported for this vcs type')
540 # Returns the srclib (name, path) used in setting up the current
551 # If the local directory exists, but is somehow not a git repository, git
552 # will traverse up the directory tree until it finds one that is (i.e.
553 # fdroidserver) and then we'll proceed to destroy it! This is called as
556 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
557 result = p.output.rstrip()
558 if not result.endswith(self.local):
559 raise VCSException('Repository mismatch')
561 def gotorevisionx(self, rev):
562 if not os.path.exists(self.local):
564 p = FDroidPopen(['git', 'clone', self.remote, self.local])
565 if p.returncode != 0:
566 self.clone_failed = True
567 raise VCSException("Git clone failed", p.output)
571 # Discard any working tree changes
572 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
573 'git', 'reset', '--hard'], cwd=self.local, output=False)
574 if p.returncode != 0:
575 raise VCSException("Git reset failed", p.output)
576 # Remove untracked files now, in case they're tracked in the target
577 # revision (it happens!)
578 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
579 'git', 'clean', '-dffx'], cwd=self.local, output=False)
580 if p.returncode != 0:
581 raise VCSException("Git clean failed", p.output)
582 if not self.refreshed:
583 # Get latest commits and tags from remote
584 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
585 if p.returncode != 0:
586 raise VCSException("Git fetch failed", p.output)
587 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
588 if p.returncode != 0:
589 raise VCSException("Git fetch failed", p.output)
590 # Recreate origin/HEAD as git clone would do it, in case it disappeared
591 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
592 if p.returncode != 0:
593 lines = p.output.splitlines()
594 if 'Multiple remote HEAD branches' not in lines[0]:
595 raise VCSException("Git remote set-head failed", p.output)
596 branch = lines[1].split(' ')[-1]
597 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
598 if p2.returncode != 0:
599 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
600 self.refreshed = True
601 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
602 # a github repo. Most of the time this is the same as origin/master.
603 rev = rev or 'origin/HEAD'
604 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
605 if p.returncode != 0:
606 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
607 # Get rid of any uncontrolled files left behind
608 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
609 if p.returncode != 0:
610 raise VCSException("Git clean failed", p.output)
612 def initsubmodules(self):
614 submfile = os.path.join(self.local, '.gitmodules')
615 if not os.path.isfile(submfile):
616 raise VCSException("No git submodules available")
618 # fix submodules not accessible without an account and public key auth
619 with open(submfile, 'r') as f:
620 lines = f.readlines()
621 with open(submfile, 'w') as f:
623 if 'git@github.com' in line:
624 line = line.replace('git@github.com:', 'https://github.com/')
627 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
628 if p.returncode != 0:
629 raise VCSException("Git submodule sync failed", p.output)
630 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
631 if p.returncode != 0:
632 raise VCSException("Git submodule update failed", p.output)
636 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
637 return p.output.splitlines()
639 def latesttags(self, tags, number):
644 ['git', 'show', '--format=format:%ct', '-s', tag],
645 cwd=self.local, output=False)
646 # Timestamp is on the last line. For a normal tag, it's the only
647 # line, but for annotated tags, the rest of the info precedes it.
648 ts = int(p.output.splitlines()[-1])
651 for _, t in sorted(tl)[-number:]:
656 class vcs_gitsvn(vcs):
661 # If the local directory exists, but is somehow not a git repository, git
662 # will traverse up the directory tree until it finds one that is (i.e.
663 # fdroidserver) and then we'll proceed to destory it! This is called as
666 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
667 result = p.output.rstrip()
668 if not result.endswith(self.local):
669 raise VCSException('Repository mismatch')
671 def gotorevisionx(self, rev):
672 if not os.path.exists(self.local):
674 gitsvn_args = ['git', 'svn', 'clone']
675 if ';' in self.remote:
676 remote_split = self.remote.split(';')
677 for i in remote_split[1:]:
678 if i.startswith('trunk='):
679 gitsvn_args.extend(['-T', i[6:]])
680 elif i.startswith('tags='):
681 gitsvn_args.extend(['-t', i[5:]])
682 elif i.startswith('branches='):
683 gitsvn_args.extend(['-b', i[9:]])
684 gitsvn_args.extend([remote_split[0], self.local])
685 p = FDroidPopen(gitsvn_args, output=False)
686 if p.returncode != 0:
687 self.clone_failed = True
688 raise VCSException("Git svn clone failed", p.output)
690 gitsvn_args.extend([self.remote, self.local])
691 p = FDroidPopen(gitsvn_args, output=False)
692 if p.returncode != 0:
693 self.clone_failed = True
694 raise VCSException("Git svn clone failed", p.output)
698 # Discard any working tree changes
699 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
700 if p.returncode != 0:
701 raise VCSException("Git reset failed", p.output)
702 # Remove untracked files now, in case they're tracked in the target
703 # revision (it happens!)
704 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
705 if p.returncode != 0:
706 raise VCSException("Git clean failed", p.output)
707 if not self.refreshed:
708 # Get new commits, branches and tags from repo
709 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
710 if p.returncode != 0:
711 raise VCSException("Git svn fetch failed")
712 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
713 if p.returncode != 0:
714 raise VCSException("Git svn rebase failed", p.output)
715 self.refreshed = True
717 rev = rev or 'master'
719 nospaces_rev = rev.replace(' ', '%20')
720 # Try finding a svn tag
721 for treeish in ['origin/', '']:
722 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
723 if p.returncode == 0:
725 if p.returncode != 0:
726 # No tag found, normal svn rev translation
727 # Translate svn rev into git format
728 rev_split = rev.split('/')
731 for treeish in ['origin/', '']:
732 if len(rev_split) > 1:
733 treeish += rev_split[0]
734 svn_rev = rev_split[1]
737 # if no branch is specified, then assume trunk (i.e. 'master' branch):
741 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
743 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
744 git_rev = p.output.rstrip()
746 if p.returncode == 0 and git_rev:
749 if p.returncode != 0 or not git_rev:
750 # Try a plain git checkout as a last resort
751 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
752 if p.returncode != 0:
753 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
755 # Check out the git rev equivalent to the svn rev
756 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
757 if p.returncode != 0:
758 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
760 # Get rid of any uncontrolled files left behind
761 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
762 if p.returncode != 0:
763 raise VCSException("Git clean failed", p.output)
767 for treeish in ['origin/', '']:
768 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
774 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
775 if p.returncode != 0:
777 return p.output.strip()
785 def gotorevisionx(self, rev):
786 if not os.path.exists(self.local):
787 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
788 if p.returncode != 0:
789 self.clone_failed = True
790 raise VCSException("Hg clone failed", p.output)
792 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
793 if p.returncode != 0:
794 raise VCSException("Hg status failed", p.output)
795 for line in p.output.splitlines():
796 if not line.startswith('? '):
797 raise VCSException("Unexpected output from hg status -uS: " + line)
798 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
799 if not self.refreshed:
800 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
801 if p.returncode != 0:
802 raise VCSException("Hg pull failed", p.output)
803 self.refreshed = True
805 rev = rev or 'default'
808 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
809 if p.returncode != 0:
810 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
811 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
812 # Also delete untracked files, we have to enable purge extension for that:
813 if "'purge' is provided by the following extension" in p.output:
814 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
815 myfile.write("\n[extensions]\nhgext.purge=\n")
816 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
817 if p.returncode != 0:
818 raise VCSException("HG purge failed", p.output)
819 elif p.returncode != 0:
820 raise VCSException("HG purge failed", p.output)
823 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
824 return p.output.splitlines()[1:]
832 def gotorevisionx(self, rev):
833 if not os.path.exists(self.local):
834 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
835 if p.returncode != 0:
836 self.clone_failed = True
837 raise VCSException("Bzr branch failed", p.output)
839 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
840 if p.returncode != 0:
841 raise VCSException("Bzr revert failed", p.output)
842 if not self.refreshed:
843 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
844 if p.returncode != 0:
845 raise VCSException("Bzr update failed", p.output)
846 self.refreshed = True
848 revargs = list(['-r', rev] if rev else [])
849 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
850 if p.returncode != 0:
851 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
854 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
855 return [tag.split(' ')[0].strip() for tag in
856 p.output.splitlines()]
859 def unescape_string(string):
860 if string[0] == '"' and string[-1] == '"':
863 return string.replace("\\'", "'")
866 def retrieve_string(app_dir, string, xmlfiles=None):
871 os.path.join(app_dir, 'res'),
872 os.path.join(app_dir, 'src', 'main', 'res'),
874 for r, d, f in os.walk(res_dir):
875 if os.path.basename(r) == 'values':
876 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
878 if not string.startswith('@string/'):
879 return unescape_string(string)
881 name = string[len('@string/'):]
883 for path in xmlfiles:
884 if not os.path.isfile(path):
886 xml = parse_xml(path)
887 element = xml.find('string[@name="' + name + '"]')
888 if element is not None and element.text is not None:
889 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
894 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
895 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
898 # Return list of existing files that will be used to find the highest vercode
899 def manifest_paths(app_dir, flavours):
901 possible_manifests = \
902 [os.path.join(app_dir, 'AndroidManifest.xml'),
903 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
904 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
905 os.path.join(app_dir, 'build.gradle')]
907 for flavour in flavours:
910 possible_manifests.append(
911 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
913 return [path for path in possible_manifests if os.path.isfile(path)]
916 # Retrieve the package name. Returns the name, or None if not found.
917 def fetch_real_name(app_dir, flavours):
918 for path in manifest_paths(app_dir, flavours):
919 if not has_extension(path, 'xml') or not os.path.isfile(path):
921 logging.debug("fetch_real_name: Checking manifest at " + path)
922 xml = parse_xml(path)
923 app = xml.find('application')
924 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
926 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
927 result = retrieve_string_singleline(app_dir, label)
929 result = result.strip()
934 def get_library_references(root_dir):
936 proppath = os.path.join(root_dir, 'project.properties')
937 if not os.path.isfile(proppath):
939 for line in file(proppath):
940 if not line.startswith('android.library.reference.'):
942 path = line.split('=')[1].strip()
943 relpath = os.path.join(root_dir, path)
944 if not os.path.isdir(relpath):
946 logging.debug("Found subproject at %s" % path)
947 libraries.append(path)
951 def ant_subprojects(root_dir):
952 subprojects = get_library_references(root_dir)
953 for subpath in subprojects:
954 subrelpath = os.path.join(root_dir, subpath)
955 for p in get_library_references(subrelpath):
956 relp = os.path.normpath(os.path.join(subpath, p))
957 if relp not in subprojects:
958 subprojects.insert(0, relp)
962 def remove_debuggable_flags(root_dir):
963 # Remove forced debuggable flags
964 logging.debug("Removing debuggable flags from %s" % root_dir)
965 for root, dirs, files in os.walk(root_dir):
966 if 'AndroidManifest.xml' in files:
967 path = os.path.join(root, 'AndroidManifest.xml')
968 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
969 if p.returncode != 0:
970 raise BuildException("Failed to remove debuggable flags of %s" % path)
973 # Extract some information from the AndroidManifest.xml at the given path.
974 # Returns (version, vercode, package), any or all of which might be None.
975 # All values returned are strings.
976 def parse_androidmanifests(paths, ignoreversions=None):
979 return (None, None, None)
981 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
982 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
983 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
985 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
993 if not os.path.isfile(path):
996 logging.debug("Parsing manifest at {0}".format(path))
997 gradle = has_extension(path, 'gradle')
1000 # Remember package name, may be defined separately from version+vercode
1001 package = max_package
1004 for line in file(path):
1006 matches = psearch_g(line)
1008 package = matches.group(1)
1010 matches = vnsearch_g(line)
1012 version = matches.group(2)
1014 matches = vcsearch_g(line)
1016 vercode = matches.group(1)
1018 xml = parse_xml(path)
1019 if "package" in xml.attrib:
1020 package = xml.attrib["package"].encode('utf-8')
1021 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1022 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1023 base_dir = os.path.dirname(path)
1024 version = retrieve_string_singleline(base_dir, version)
1025 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1026 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1027 if string_is_integer(a):
1030 logging.debug("..got package={0}, version={1}, vercode={2}"
1031 .format(package, version, vercode))
1033 # Always grab the package name and version name in case they are not
1034 # together with the highest version code
1035 if max_package is None and package is not None:
1036 max_package = package
1037 if max_version is None and version is not None:
1038 max_version = version
1040 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1041 if not ignoresearch or not ignoresearch(version):
1042 if version is not None:
1043 max_version = version
1044 if vercode is not None:
1045 max_vercode = vercode
1046 if package is not None:
1047 max_package = package
1049 max_version = "Ignore"
1051 if max_version is None:
1052 max_version = "Unknown"
1054 if max_package and not is_valid_package_name(max_package):
1055 raise FDroidException("Invalid package name {0}".format(max_package))
1057 return (max_version, max_vercode, max_package)
1060 def is_valid_package_name(name):
1061 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1064 class FDroidException(Exception):
1066 def __init__(self, value, detail=None):
1068 self.detail = detail
1070 def get_wikitext(self):
1071 ret = repr(self.value) + "\n"
1075 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1083 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1087 class VCSException(FDroidException):
1091 class BuildException(FDroidException):
1095 # Get the specified source library.
1096 # Returns the path to it. Normally this is the path to be used when referencing
1097 # it, which may be a subdirectory of the actual project. If you want the base
1098 # directory of the project, pass 'basepath=True'.
1099 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1100 raw=False, prepare=True, preponly=False, refresh=True):
1108 name, ref = spec.split('@')
1110 number, name = name.split(':', 1)
1112 name, subdir = name.split('/', 1)
1114 if name not in metadata.srclibs:
1115 raise VCSException('srclib ' + name + ' not found.')
1117 srclib = metadata.srclibs[name]
1119 sdir = os.path.join(srclib_dir, name)
1122 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1123 vcs.srclib = (name, number, sdir)
1125 vcs.gotorevision(ref, refresh)
1132 libdir = os.path.join(sdir, subdir)
1133 elif srclib["Subdir"]:
1134 for subdir in srclib["Subdir"]:
1135 libdir_candidate = os.path.join(sdir, subdir)
1136 if os.path.exists(libdir_candidate):
1137 libdir = libdir_candidate
1143 remove_signing_keys(sdir)
1144 remove_debuggable_flags(sdir)
1148 if srclib["Prepare"]:
1149 cmd = replace_config_vars(srclib["Prepare"], None)
1151 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1152 if p.returncode != 0:
1153 raise BuildException("Error running prepare command for srclib %s"
1159 return (name, number, libdir)
1162 # Prepare the source code for a particular build
1163 # 'vcs' - the appropriate vcs object for the application
1164 # 'app' - the application details from the metadata
1165 # 'build' - the build details from the metadata
1166 # 'build_dir' - the path to the build directory, usually
1168 # 'srclib_dir' - the path to the source libraries directory, usually
1170 # 'extlib_dir' - the path to the external libraries directory, usually
1172 # Returns the (root, srclibpaths) where:
1173 # 'root' is the root directory, which may be the same as 'build_dir' or may
1174 # be a subdirectory of it.
1175 # 'srclibpaths' is information on the srclibs being used
1176 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1178 # Optionally, the actual app source can be in a subdirectory
1180 root_dir = os.path.join(build_dir, build['subdir'])
1182 root_dir = build_dir
1184 # Get a working copy of the right revision
1185 logging.info("Getting source for revision " + build['commit'])
1186 vcs.gotorevision(build['commit'], refresh)
1188 # Initialise submodules if required
1189 if build['submodules']:
1190 logging.info("Initialising submodules")
1191 vcs.initsubmodules()
1193 # Check that a subdir (if we're using one) exists. This has to happen
1194 # after the checkout, since it might not exist elsewhere
1195 if not os.path.exists(root_dir):
1196 raise BuildException('Missing subdir ' + root_dir)
1198 # Run an init command if one is required
1200 cmd = replace_config_vars(build['init'], build)
1201 logging.info("Running 'init' commands in %s" % root_dir)
1203 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1204 if p.returncode != 0:
1205 raise BuildException("Error running init command for %s:%s" %
1206 (app['id'], build['version']), p.output)
1208 # Apply patches if any
1210 logging.info("Applying patches")
1211 for patch in build['patch']:
1212 patch = patch.strip()
1213 logging.info("Applying " + patch)
1214 patch_path = os.path.join('metadata', app['id'], patch)
1215 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1216 if p.returncode != 0:
1217 raise BuildException("Failed to apply patch %s" % patch_path)
1219 # Get required source libraries
1221 if build['srclibs']:
1222 logging.info("Collecting source libraries")
1223 for lib in build['srclibs']:
1224 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1226 for name, number, libpath in srclibpaths:
1227 place_srclib(root_dir, int(number) if number else None, libpath)
1229 basesrclib = vcs.getsrclib()
1230 # If one was used for the main source, add that too.
1232 srclibpaths.append(basesrclib)
1234 # Update the local.properties file
1235 localprops = [os.path.join(build_dir, 'local.properties')]
1237 localprops += [os.path.join(root_dir, 'local.properties')]
1238 for path in localprops:
1240 if os.path.isfile(path):
1241 logging.info("Updating local.properties file at %s" % path)
1247 logging.info("Creating local.properties file at %s" % path)
1248 # Fix old-fashioned 'sdk-location' by copying
1249 # from sdk.dir, if necessary
1250 if build['oldsdkloc']:
1251 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1252 re.S | re.M).group(1)
1253 props += "sdk-location=%s\n" % sdkloc
1255 props += "sdk.dir=%s\n" % config['sdk_path']
1256 props += "sdk-location=%s\n" % config['sdk_path']
1257 if build['ndk_path']:
1259 props += "ndk.dir=%s\n" % build['ndk_path']
1260 props += "ndk-location=%s\n" % build['ndk_path']
1261 # Add java.encoding if necessary
1262 if build['encoding']:
1263 props += "java.encoding=%s\n" % build['encoding']
1269 if build['type'] == 'gradle':
1270 flavours = build['gradle']
1272 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1273 gradlepluginver = None
1275 gradle_dirs = [root_dir]
1277 # Parent dir build.gradle
1278 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1279 if parent_dir.startswith(build_dir):
1280 gradle_dirs.append(parent_dir)
1282 for dir_path in gradle_dirs:
1285 if not os.path.isdir(dir_path):
1287 for filename in os.listdir(dir_path):
1288 if not filename.endswith('.gradle'):
1290 path = os.path.join(dir_path, filename)
1291 if not os.path.isfile(path):
1293 for line in file(path):
1294 match = version_regex.match(line)
1296 gradlepluginver = match.group(1)
1300 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1302 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1303 build['gradlepluginver'] = LooseVersion('0.11')
1306 n = build["target"].split('-')[1]
1307 FDroidPopen(['sed', '-i',
1308 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1309 'build.gradle'], cwd=root_dir, output=False)
1311 # Remove forced debuggable flags
1312 remove_debuggable_flags(root_dir)
1314 # Insert version code and number into the manifest if necessary
1315 if build['forceversion']:
1316 logging.info("Changing the version name")
1317 for path in manifest_paths(root_dir, flavours):
1318 if not os.path.isfile(path):
1320 if has_extension(path, 'xml'):
1321 p = FDroidPopen(['sed', '-i',
1322 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1323 path], output=False)
1324 if p.returncode != 0:
1325 raise BuildException("Failed to amend manifest")
1326 elif has_extension(path, 'gradle'):
1327 p = FDroidPopen(['sed', '-i',
1328 's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1329 path], output=False)
1330 if p.returncode != 0:
1331 raise BuildException("Failed to amend build.gradle")
1332 if build['forcevercode']:
1333 logging.info("Changing the version code")
1334 for path in manifest_paths(root_dir, flavours):
1335 if not os.path.isfile(path):
1337 if has_extension(path, 'xml'):
1338 p = FDroidPopen(['sed', '-i',
1339 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1340 path], output=False)
1341 if p.returncode != 0:
1342 raise BuildException("Failed to amend manifest")
1343 elif has_extension(path, 'gradle'):
1344 p = FDroidPopen(['sed', '-i',
1345 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1346 path], output=False)
1347 if p.returncode != 0:
1348 raise BuildException("Failed to amend build.gradle")
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 # Scan the source code in the given directory (and all subdirectories)
1444 # and return the number of fatal problems encountered
1445 def scan_source(build_dir, root_dir, thisbuild):
1449 # Common known non-free blobs (always lower case):
1451 re.compile(r'.*flurryagent', re.IGNORECASE),
1452 re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1453 re.compile(r'.*google.*analytics', re.IGNORECASE),
1454 re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1455 re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1456 re.compile(r'.*google.*admob', re.IGNORECASE),
1457 re.compile(r'.*google.*play.*services', re.IGNORECASE),
1458 re.compile(r'.*crittercism', re.IGNORECASE),
1459 re.compile(r'.*heyzap', re.IGNORECASE),
1460 re.compile(r'.*jpct.*ae', re.IGNORECASE),
1461 re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1462 re.compile(r'.*bugsense', re.IGNORECASE),
1463 re.compile(r'.*crashlytics', re.IGNORECASE),
1464 re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1465 re.compile(r'.*libspen23', re.IGNORECASE),
1468 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1469 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1471 scanignore_worked = set()
1472 scandelete_worked = set()
1475 ms = magic.open(magic.MIME_TYPE)
1477 except AttributeError:
1481 for p in scanignore:
1482 if fd.startswith(p):
1483 scanignore_worked.add(p)
1488 for p in scandelete:
1489 if fd.startswith(p):
1490 scandelete_worked.add(p)
1494 def ignoreproblem(what, fd, fp):
1495 logging.info('Ignoring %s at %s' % (what, fd))
1498 def removeproblem(what, fd, fp):
1499 logging.info('Removing %s at %s' % (what, fd))
1503 def warnproblem(what, fd):
1504 logging.warn('Found %s at %s' % (what, fd))
1506 def handleproblem(what, fd, fp):
1508 return ignoreproblem(what, fd, fp)
1510 return removeproblem(what, fd, fp)
1511 logging.error('Found %s at %s' % (what, fd))
1514 # Iterate through all files in the source code
1515 for r, d, f in os.walk(build_dir, topdown=True):
1517 # It's topdown, so checking the basename is enough
1518 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1524 # Path (relative) to the file
1525 fp = os.path.join(r, curfile)
1526 fd = fp[len(build_dir) + 1:]
1529 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1530 except UnicodeError:
1531 warnproblem('malformed magic number', fd)
1533 if mime == 'application/x-sharedlib':
1534 count += handleproblem('shared library', fd, fp)
1536 elif mime == 'application/x-archive':
1537 count += handleproblem('static library', fd, fp)
1539 elif mime == 'application/x-executable':
1540 count += handleproblem('binary executable', fd, fp)
1542 elif mime == 'application/x-java-applet':
1543 count += handleproblem('Java compiled class', fd, fp)
1548 'application/java-archive',
1549 'application/octet-stream',
1552 if has_extension(fp, 'apk'):
1553 removeproblem('APK file', fd, fp)
1555 elif has_extension(fp, 'jar'):
1557 if any(suspect.match(curfile) for suspect in usual_suspects):
1558 count += handleproblem('usual supect', fd, fp)
1560 warnproblem('JAR file', fd)
1562 elif has_extension(fp, 'zip'):
1563 warnproblem('ZIP file', fd)
1566 warnproblem('unknown compressed or binary file', fd)
1568 elif has_extension(fp, 'java'):
1569 if not os.path.isfile(fp):
1571 for line in file(fp):
1572 if 'DexClassLoader' in line:
1573 count += handleproblem('DexClassLoader', fd, fp)
1576 elif has_extension(fp, 'gradle'):
1577 if not os.path.isfile(fp):
1579 for i, line in enumerate(file(fp)):
1580 if any(suspect.match(line) for suspect in usual_suspects):
1581 count += handleproblem('usual suspect at line %d' % i, fd, fp)
1586 for p in scanignore:
1587 if p not in scanignore_worked:
1588 logging.error('Unused scanignore path: %s' % p)
1591 for p in scandelete:
1592 if p not in scandelete_worked:
1593 logging.error('Unused scandelete path: %s' % p)
1596 # Presence of a jni directory without buildjni=yes might
1597 # indicate a problem (if it's not a problem, explicitly use
1598 # buildjni=no to bypass this check)
1599 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1600 not thisbuild['buildjni']):
1601 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1610 self.path = os.path.join('stats', 'known_apks.txt')
1612 if os.path.isfile(self.path):
1613 for line in file(self.path):
1614 t = line.rstrip().split(' ')
1616 self.apks[t[0]] = (t[1], None)
1618 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1619 self.changed = False
1621 def writeifchanged(self):
1623 if not os.path.exists('stats'):
1625 f = open(self.path, 'w')
1627 for apk, app in self.apks.iteritems():
1629 line = apk + ' ' + appid
1631 line += ' ' + time.strftime('%Y-%m-%d', added)
1633 for line in sorted(lst):
1634 f.write(line + '\n')
1637 # Record an apk (if it's new, otherwise does nothing)
1638 # Returns the date it was added.
1639 def recordapk(self, apk, app):
1640 if apk not in self.apks:
1641 self.apks[apk] = (app, time.gmtime(time.time()))
1643 _, added = self.apks[apk]
1646 # Look up information - given the 'apkname', returns (app id, date added/None).
1647 # Or returns None for an unknown apk.
1648 def getapp(self, apkname):
1649 if apkname in self.apks:
1650 return self.apks[apkname]
1653 # Get the most recent 'num' apps added to the repo, as a list of package ids
1654 # with the most recent first.
1655 def getlatest(self, num):
1657 for apk, app in self.apks.iteritems():
1661 if apps[appid] > added:
1665 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1666 lst = [app for app, _ in sortedapps]
1671 def isApkDebuggable(apkfile, config):
1672 """Returns True if the given apk file is debuggable
1674 :param apkfile: full path to the apk to check"""
1676 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1678 if p.returncode != 0:
1679 logging.critical("Failed to get apk manifest information")
1681 for line in p.output.splitlines():
1682 if 'android:debuggable' in line and not line.endswith('0x0'):
1687 class AsynchronousFileReader(threading.Thread):
1690 Helper class to implement asynchronous reading of a file
1691 in a separate thread. Pushes read lines on a queue to
1692 be consumed in another thread.
1695 def __init__(self, fd, queue):
1696 assert isinstance(queue, Queue.Queue)
1697 assert callable(fd.readline)
1698 threading.Thread.__init__(self)
1703 '''The body of the tread: read lines and put them on the queue.'''
1704 for line in iter(self._fd.readline, ''):
1705 self._queue.put(line)
1708 '''Check whether there is no more content to expect.'''
1709 return not self.is_alive() and self._queue.empty()
1717 def SdkToolsPopen(commands, cwd=None, output=True):
1719 if cmd not in config:
1720 config[cmd] = find_sdk_tools_cmd(commands[0])
1721 return FDroidPopen([config[cmd]] + commands[1:],
1722 cwd=cwd, output=output)
1725 def FDroidPopen(commands, cwd=None, output=True):
1727 Run a command and capture the possibly huge output.
1729 :param commands: command and argument list like in subprocess.Popen
1730 :param cwd: optionally specifies a working directory
1731 :returns: A PopenResult.
1737 cwd = os.path.normpath(cwd)
1738 logging.debug("Directory: %s" % cwd)
1739 logging.debug("> %s" % ' '.join(commands))
1741 result = PopenResult()
1744 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1745 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1747 raise BuildException("OSError while trying to execute " +
1748 ' '.join(commands) + ': ' + str(e))
1750 stdout_queue = Queue.Queue()
1751 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1752 stdout_reader.start()
1754 # Check the queue for output (until there is no more to get)
1755 while not stdout_reader.eof():
1756 while not stdout_queue.empty():
1757 line = stdout_queue.get()
1758 if output and options.verbose:
1759 # Output directly to console
1760 sys.stderr.write(line)
1762 result.output += line
1766 result.returncode = p.wait()
1770 def remove_signing_keys(build_dir):
1771 comment = re.compile(r'[ ]*//')
1772 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1774 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1775 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1776 re.compile(r'.*variant\.outputFile = .*'),
1777 re.compile(r'.*output\.outputFile = .*'),
1778 re.compile(r'.*\.readLine\(.*'),
1780 for root, dirs, files in os.walk(build_dir):
1781 if 'build.gradle' in files:
1782 path = os.path.join(root, 'build.gradle')
1784 with open(path, "r") as o:
1785 lines = o.readlines()
1791 with open(path, "w") as o:
1792 while i < len(lines):
1795 while line.endswith('\\\n'):
1796 line = line.rstrip('\\\n') + lines[i]
1799 if comment.match(line):
1803 opened += line.count('{')
1804 opened -= line.count('}')
1807 if signing_configs.match(line):
1812 if any(s.match(line) for s in line_matches):
1820 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1823 'project.properties',
1825 'default.properties',
1826 'ant.properties', ]:
1827 if propfile in files:
1828 path = os.path.join(root, propfile)
1830 with open(path, "r") as o:
1831 lines = o.readlines()
1835 with open(path, "w") as o:
1837 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1844 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1847 def reset_env_path():
1848 global env, orig_path
1849 env['PATH'] = orig_path
1852 def add_to_env_path(path):
1854 paths = env['PATH'].split(os.pathsep)
1858 env['PATH'] = os.pathsep.join(paths)
1861 def replace_config_vars(cmd, build):
1863 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1864 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1865 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1866 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1867 if build is not None:
1868 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1869 cmd = cmd.replace('$$VERSION$$', build['version'])
1870 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1874 def place_srclib(root_dir, number, libpath):
1877 relpath = os.path.relpath(libpath, root_dir)
1878 proppath = os.path.join(root_dir, 'project.properties')
1881 if os.path.isfile(proppath):
1882 with open(proppath, "r") as o:
1883 lines = o.readlines()
1885 with open(proppath, "w") as o:
1888 if line.startswith('android.library.reference.%d=' % number):
1889 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1894 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1897 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1898 """Verify that two apks are the same
1900 One of the inputs is signed, the other is unsigned. The signature metadata
1901 is transferred from the signed to the unsigned apk, and then jarsigner is
1902 used to verify that the signature from the signed apk is also varlid for
1904 :param signed_apk: Path to a signed apk file
1905 :param unsigned_apk: Path to an unsigned apk file expected to match it
1906 :param tmp_dir: Path to directory for temporary files
1907 :returns: None if the verification is successful, otherwise a string
1908 describing what went wrong.
1910 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1911 with ZipFile(signed_apk) as signed_apk_as_zip:
1912 meta_inf_files = ['META-INF/MANIFEST.MF']
1913 for f in signed_apk_as_zip.namelist():
1914 if sigfile.match(f):
1915 meta_inf_files.append(f)
1916 if len(meta_inf_files) < 3:
1917 return "Signature files missing from {0}".format(signed_apk)
1918 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1919 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1920 for meta_inf_file in meta_inf_files:
1921 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1923 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1924 logging.info("...NOT verified - {0}".format(signed_apk))
1925 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1926 logging.info("...successfully verified")
1930 def compare_apks(apk1, apk2, tmp_dir):
1933 Returns None if the apk content is the same (apart from the signing key),
1934 otherwise a string describing what's different, or what went wrong when
1935 trying to do the comparison.
1938 badchars = re.compile('''[/ :;'"]''')
1939 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1940 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1941 for d in [apk1dir, apk2dir]:
1942 if os.path.exists(d):
1945 os.mkdir(os.path.join(d, 'jar-xf'))
1947 if subprocess.call(['jar', 'xf',
1948 os.path.abspath(apk1)],
1949 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1950 return("Failed to unpack " + apk1)
1951 if subprocess.call(['jar', 'xf',
1952 os.path.abspath(apk2)],
1953 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1954 return("Failed to unpack " + apk2)
1956 # try to find apktool in the path, if it hasn't been manually configed
1957 if 'apktool' not in config:
1958 tmp = find_command('apktool')
1960 config['apktool'] = tmp
1961 if 'apktool' in config:
1962 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1964 return("Failed to unpack " + apk1)
1965 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1967 return("Failed to unpack " + apk2)
1969 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1970 lines = p.output.splitlines()
1971 if len(lines) != 1 or 'META-INF' not in lines[0]:
1972 meld = find_command('meld')
1973 if meld is not None:
1974 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1975 return("Unexpected diff output - " + p.output)
1977 # since everything verifies, delete the comparison to keep cruft down
1978 shutil.rmtree(apk1dir)
1979 shutil.rmtree(apk2dir)
1981 # If we get here, it seems like they're the same!
1985 def find_command(command):
1986 '''find the full path of a command, or None if it can't be found in the PATH'''
1989 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1991 fpath, fname = os.path.split(command)
1996 for path in os.environ["PATH"].split(os.pathsep):
1997 path = path.strip('"')
1998 exe_file = os.path.join(path, command)
1999 if is_exe(exe_file):
2006 '''generate a random password for when generating keys'''
2007 h = hashlib.sha256()
2008 h.update(os.urandom(16)) # salt
2009 h.update(bytes(socket.getfqdn()))
2010 return h.digest().encode('base64').strip()
2013 def genkeystore(localconfig):
2014 '''Generate a new key with random passwords and add it to new keystore'''
2015 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2016 keystoredir = os.path.dirname(localconfig['keystore'])
2017 if keystoredir is None or keystoredir == '':
2018 keystoredir = os.path.join(os.getcwd(), keystoredir)
2019 if not os.path.exists(keystoredir):
2020 os.makedirs(keystoredir, mode=0o700)
2022 write_password_file("keystorepass", localconfig['keystorepass'])
2023 write_password_file("keypass", localconfig['keypass'])
2024 p = FDroidPopen(['keytool', '-genkey',
2025 '-keystore', localconfig['keystore'],
2026 '-alias', localconfig['repo_keyalias'],
2027 '-keyalg', 'RSA', '-keysize', '4096',
2028 '-sigalg', 'SHA256withRSA',
2029 '-validity', '10000',
2030 '-storepass:file', config['keystorepassfile'],
2031 '-keypass:file', config['keypassfile'],
2032 '-dname', localconfig['keydname']])
2033 # TODO keypass should be sent via stdin
2034 os.chmod(localconfig['keystore'], 0o0600)
2035 if p.returncode != 0:
2036 raise BuildException("Failed to generate key", p.output)
2037 # now show the lovely key that was just generated
2038 p = FDroidPopen(['keytool', '-list', '-v',
2039 '-keystore', localconfig['keystore'],
2040 '-alias', localconfig['repo_keyalias'],
2041 '-storepass:file', config['keystorepassfile']])
2042 logging.info(p.output.strip() + '\n\n')
2045 def write_to_config(thisconfig, key, value=None):
2046 '''write a key/value to the local config.py'''
2048 origkey = key + '_orig'
2049 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2050 with open('config.py', 'r') as f:
2052 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2053 repl = '\n' + key + ' = "' + value + '"'
2054 data = re.sub(pattern, repl, data)
2055 # if this key is not in the file, append it
2056 if not re.match('\s*' + key + '\s*=\s*"', data):
2058 # make sure the file ends with a carraige return
2059 if not re.match('\n$', data):
2061 with open('config.py', 'w') as f:
2065 def parse_xml(path):
2066 return XMLElementTree.parse(path).getroot()
2069 def string_is_integer(string):