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):
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)
493 self.gotorevisionx(rev)
494 except FDroidException, e:
497 # If necessary, write the .fdroidvcs file.
498 if writeback and not self.clone_failed:
499 with open(fdpath, 'w') as f:
505 # Derived classes need to implement this. It's called once basic checking
506 # has been performend.
507 def gotorevisionx(self, rev):
508 raise VCSException("This VCS type doesn't define gotorevisionx")
510 # Initialise and update submodules
511 def initsubmodules(self):
512 raise VCSException('Submodules not supported for this vcs type')
514 # Get a list of all known tags
516 if not self._gettags:
517 raise VCSException('gettags not supported for this vcs type')
519 for tag in self._gettags():
520 if re.match('[-A-Za-z0-9_. ]+$', tag):
524 def latesttags(self, tags, number):
525 """Get the most recent tags in a given list.
527 :param tags: a list of tags
528 :param number: the number to return
529 :returns: A list containing the most recent tags in the provided
530 list, up to the maximum number given.
532 raise VCSException('latesttags not supported for this vcs type')
534 # Get current commit reference (hash, revision, etc)
536 raise VCSException('getref not supported for this vcs type')
538 # Returns the srclib (name, path) used in setting up the current
549 # If the local directory exists, but is somehow not a git repository, git
550 # will traverse up the directory tree until it finds one that is (i.e.
551 # fdroidserver) and then we'll proceed to destroy it! This is called as
554 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
555 result = p.output.rstrip()
556 if not result.endswith(self.local):
557 raise VCSException('Repository mismatch')
559 def gotorevisionx(self, rev):
560 if not os.path.exists(self.local):
562 p = FDroidPopen(['git', 'clone', self.remote, self.local])
563 if p.returncode != 0:
564 self.clone_failed = True
565 raise VCSException("Git clone failed", p.output)
569 # Discard any working tree changes
570 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
571 'git', 'reset', '--hard'], cwd=self.local, output=False)
572 if p.returncode != 0:
573 raise VCSException("Git reset failed", p.output)
574 # Remove untracked files now, in case they're tracked in the target
575 # revision (it happens!)
576 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
577 'git', 'clean', '-dffx'], cwd=self.local, output=False)
578 if p.returncode != 0:
579 raise VCSException("Git clean failed", p.output)
580 if not self.refreshed:
581 # Get latest commits and tags from remote
582 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
583 if p.returncode != 0:
584 raise VCSException("Git fetch failed", p.output)
585 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
586 if p.returncode != 0:
587 raise VCSException("Git fetch failed", p.output)
588 # Recreate origin/HEAD as git clone would do it, in case it disappeared
589 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
590 if p.returncode != 0:
591 lines = p.output.splitlines()
592 if 'Multiple remote HEAD branches' not in lines[0]:
593 raise VCSException("Git remote set-head failed", p.output)
594 branch = lines[1].split(' ')[-1]
595 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
596 if p2.returncode != 0:
597 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
598 self.refreshed = True
599 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
600 # a github repo. Most of the time this is the same as origin/master.
601 rev = rev or 'origin/HEAD'
602 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
603 if p.returncode != 0:
604 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
605 # Get rid of any uncontrolled files left behind
606 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
607 if p.returncode != 0:
608 raise VCSException("Git clean failed", p.output)
610 def initsubmodules(self):
612 submfile = os.path.join(self.local, '.gitmodules')
613 if not os.path.isfile(submfile):
614 raise VCSException("No git submodules available")
616 # fix submodules not accessible without an account and public key auth
617 with open(submfile, 'r') as f:
618 lines = f.readlines()
619 with open(submfile, 'w') as f:
621 if 'git@github.com' in line:
622 line = line.replace('git@github.com:', 'https://github.com/')
625 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
626 if p.returncode != 0:
627 raise VCSException("Git submodule sync failed", p.output)
628 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
629 if p.returncode != 0:
630 raise VCSException("Git submodule update failed", p.output)
634 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
635 return p.output.splitlines()
637 def latesttags(self, tags, number):
642 ['git', 'show', '--format=format:%ct', '-s', tag],
643 cwd=self.local, output=False)
644 # Timestamp is on the last line. For a normal tag, it's the only
645 # line, but for annotated tags, the rest of the info precedes it.
646 ts = int(p.output.splitlines()[-1])
649 for _, t in sorted(tl)[-number:]:
654 class vcs_gitsvn(vcs):
659 # If the local directory exists, but is somehow not a git repository, git
660 # will traverse up the directory tree until it finds one that is (i.e.
661 # fdroidserver) and then we'll proceed to destory it! This is called as
664 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
665 result = p.output.rstrip()
666 if not result.endswith(self.local):
667 raise VCSException('Repository mismatch')
669 def gotorevisionx(self, rev):
670 if not os.path.exists(self.local):
672 gitsvn_args = ['git', 'svn', 'clone']
673 if ';' in self.remote:
674 remote_split = self.remote.split(';')
675 for i in remote_split[1:]:
676 if i.startswith('trunk='):
677 gitsvn_args.extend(['-T', i[6:]])
678 elif i.startswith('tags='):
679 gitsvn_args.extend(['-t', i[5:]])
680 elif i.startswith('branches='):
681 gitsvn_args.extend(['-b', i[9:]])
682 gitsvn_args.extend([remote_split[0], self.local])
683 p = FDroidPopen(gitsvn_args, output=False)
684 if p.returncode != 0:
685 self.clone_failed = True
686 raise VCSException("Git svn clone failed", p.output)
688 gitsvn_args.extend([self.remote, self.local])
689 p = FDroidPopen(gitsvn_args, output=False)
690 if p.returncode != 0:
691 self.clone_failed = True
692 raise VCSException("Git svn clone failed", p.output)
696 # Discard any working tree changes
697 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
698 if p.returncode != 0:
699 raise VCSException("Git reset failed", p.output)
700 # Remove untracked files now, in case they're tracked in the target
701 # revision (it happens!)
702 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
703 if p.returncode != 0:
704 raise VCSException("Git clean failed", p.output)
705 if not self.refreshed:
706 # Get new commits, branches and tags from repo
707 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
708 if p.returncode != 0:
709 raise VCSException("Git svn fetch failed")
710 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
711 if p.returncode != 0:
712 raise VCSException("Git svn rebase failed", p.output)
713 self.refreshed = True
715 rev = rev or 'master'
717 nospaces_rev = rev.replace(' ', '%20')
718 # Try finding a svn tag
719 for treeish in ['origin/', '']:
720 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
721 if p.returncode == 0:
723 if p.returncode != 0:
724 # No tag found, normal svn rev translation
725 # Translate svn rev into git format
726 rev_split = rev.split('/')
729 for treeish in ['origin/', '']:
730 if len(rev_split) > 1:
731 treeish += rev_split[0]
732 svn_rev = rev_split[1]
735 # if no branch is specified, then assume trunk (i.e. 'master' branch):
739 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
741 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
742 git_rev = p.output.rstrip()
744 if p.returncode == 0 and git_rev:
747 if p.returncode != 0 or not git_rev:
748 # Try a plain git checkout as a last resort
749 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
750 if p.returncode != 0:
751 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
753 # Check out the git rev equivalent to the svn rev
754 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
755 if p.returncode != 0:
756 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
758 # Get rid of any uncontrolled files left behind
759 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
760 if p.returncode != 0:
761 raise VCSException("Git clean failed", p.output)
765 for treeish in ['origin/', '']:
766 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
772 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
773 if p.returncode != 0:
775 return p.output.strip()
783 def gotorevisionx(self, rev):
784 if not os.path.exists(self.local):
785 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
786 if p.returncode != 0:
787 self.clone_failed = True
788 raise VCSException("Hg clone failed", p.output)
790 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
791 if p.returncode != 0:
792 raise VCSException("Hg status failed", p.output)
793 for line in p.output.splitlines():
794 if not line.startswith('? '):
795 raise VCSException("Unexpected output from hg status -uS: " + line)
796 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
797 if not self.refreshed:
798 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
799 if p.returncode != 0:
800 raise VCSException("Hg pull failed", p.output)
801 self.refreshed = True
803 rev = rev or 'default'
806 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
807 if p.returncode != 0:
808 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
809 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
810 # Also delete untracked files, we have to enable purge extension for that:
811 if "'purge' is provided by the following extension" in p.output:
812 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
813 myfile.write("\n[extensions]\nhgext.purge=\n")
814 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
815 if p.returncode != 0:
816 raise VCSException("HG purge failed", p.output)
817 elif p.returncode != 0:
818 raise VCSException("HG purge failed", p.output)
821 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
822 return p.output.splitlines()[1:]
830 def gotorevisionx(self, rev):
831 if not os.path.exists(self.local):
832 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
833 if p.returncode != 0:
834 self.clone_failed = True
835 raise VCSException("Bzr branch failed", p.output)
837 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
838 if p.returncode != 0:
839 raise VCSException("Bzr revert failed", p.output)
840 if not self.refreshed:
841 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("Bzr update failed", p.output)
844 self.refreshed = True
846 revargs = list(['-r', rev] if rev else [])
847 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
848 if p.returncode != 0:
849 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
852 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
853 return [tag.split(' ')[0].strip() for tag in
854 p.output.splitlines()]
857 def unescape_string(string):
858 if string[0] == '"' and string[-1] == '"':
861 return string.replace("\\'", "'")
864 def retrieve_string(app_dir, string, xmlfiles=None):
869 os.path.join(app_dir, 'res'),
870 os.path.join(app_dir, 'src', 'main', 'res'),
872 for r, d, f in os.walk(res_dir):
873 if os.path.basename(r) == 'values':
874 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
876 if not string.startswith('@string/'):
877 return unescape_string(string)
879 name = string[len('@string/'):]
881 for path in xmlfiles:
882 if not os.path.isfile(path):
884 xml = parse_xml(path)
885 element = xml.find('string[@name="' + name + '"]')
886 if element is not None and element.text is not None:
887 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
892 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
893 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
896 # Return list of existing files that will be used to find the highest vercode
897 def manifest_paths(app_dir, flavours):
899 possible_manifests = \
900 [os.path.join(app_dir, 'AndroidManifest.xml'),
901 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
902 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
903 os.path.join(app_dir, 'build.gradle')]
905 for flavour in flavours:
908 possible_manifests.append(
909 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
911 return [path for path in possible_manifests if os.path.isfile(path)]
914 # Retrieve the package name. Returns the name, or None if not found.
915 def fetch_real_name(app_dir, flavours):
916 for path in manifest_paths(app_dir, flavours):
917 if not has_extension(path, 'xml') or not os.path.isfile(path):
919 logging.debug("fetch_real_name: Checking manifest at " + path)
920 xml = parse_xml(path)
921 app = xml.find('application')
922 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
924 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
925 result = retrieve_string_singleline(app_dir, label)
927 result = result.strip()
932 def get_library_references(root_dir):
934 proppath = os.path.join(root_dir, 'project.properties')
935 if not os.path.isfile(proppath):
937 for line in file(proppath):
938 if not line.startswith('android.library.reference.'):
940 path = line.split('=')[1].strip()
941 relpath = os.path.join(root_dir, path)
942 if not os.path.isdir(relpath):
944 logging.debug("Found subproject at %s" % path)
945 libraries.append(path)
949 def ant_subprojects(root_dir):
950 subprojects = get_library_references(root_dir)
951 for subpath in subprojects:
952 subrelpath = os.path.join(root_dir, subpath)
953 for p in get_library_references(subrelpath):
954 relp = os.path.normpath(os.path.join(subpath, p))
955 if relp not in subprojects:
956 subprojects.insert(0, relp)
960 def remove_debuggable_flags(root_dir):
961 # Remove forced debuggable flags
962 logging.debug("Removing debuggable flags from %s" % root_dir)
963 for root, dirs, files in os.walk(root_dir):
964 if 'AndroidManifest.xml' in files:
965 path = os.path.join(root, 'AndroidManifest.xml')
966 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
967 if p.returncode != 0:
968 raise BuildException("Failed to remove debuggable flags of %s" % path)
971 # Extract some information from the AndroidManifest.xml at the given path.
972 # Returns (version, vercode, package), any or all of which might be None.
973 # All values returned are strings.
974 def parse_androidmanifests(paths, ignoreversions=None):
977 return (None, None, None)
979 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
980 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
981 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
983 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
991 if not os.path.isfile(path):
994 logging.debug("Parsing manifest at {0}".format(path))
995 gradle = has_extension(path, 'gradle')
998 # Remember package name, may be defined separately from version+vercode
999 package = max_package
1002 for line in file(path):
1004 matches = psearch_g(line)
1006 package = matches.group(1)
1008 matches = vnsearch_g(line)
1010 version = matches.group(2)
1012 matches = vcsearch_g(line)
1014 vercode = matches.group(1)
1016 xml = parse_xml(path)
1017 if "package" in xml.attrib:
1018 package = xml.attrib["package"].encode('utf-8')
1019 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1020 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1021 base_dir = os.path.dirname(path)
1022 version = retrieve_string_singleline(base_dir, version)
1023 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1024 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1025 if string_is_integer(a):
1028 logging.debug("..got package={0}, version={1}, vercode={2}"
1029 .format(package, version, vercode))
1031 # Always grab the package name and version name in case they are not
1032 # together with the highest version code
1033 if max_package is None and package is not None:
1034 max_package = package
1035 if max_version is None and version is not None:
1036 max_version = version
1038 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1039 if not ignoresearch or not ignoresearch(version):
1040 if version is not None:
1041 max_version = version
1042 if vercode is not None:
1043 max_vercode = vercode
1044 if package is not None:
1045 max_package = package
1047 max_version = "Ignore"
1049 if max_version is None:
1050 max_version = "Unknown"
1052 if max_package and not is_valid_package_name(max_package):
1053 raise FDroidException("Invalid package name {0}".format(max_package))
1055 return (max_version, max_vercode, max_package)
1058 def is_valid_package_name(name):
1059 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1062 class FDroidException(Exception):
1064 def __init__(self, value, detail=None):
1066 self.detail = detail
1068 def get_wikitext(self):
1069 ret = repr(self.value) + "\n"
1073 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1081 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1085 class VCSException(FDroidException):
1089 class BuildException(FDroidException):
1093 # Get the specified source library.
1094 # Returns the path to it. Normally this is the path to be used when referencing
1095 # it, which may be a subdirectory of the actual project. If you want the base
1096 # directory of the project, pass 'basepath=True'.
1097 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1098 raw=False, prepare=True, preponly=False):
1106 name, ref = spec.split('@')
1108 number, name = name.split(':', 1)
1110 name, subdir = name.split('/', 1)
1112 if name not in metadata.srclibs:
1113 raise VCSException('srclib ' + name + ' not found.')
1115 srclib = metadata.srclibs[name]
1117 sdir = os.path.join(srclib_dir, name)
1120 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1121 vcs.srclib = (name, number, sdir)
1123 vcs.gotorevision(ref)
1130 libdir = os.path.join(sdir, subdir)
1131 elif srclib["Subdir"]:
1132 for subdir in srclib["Subdir"]:
1133 libdir_candidate = os.path.join(sdir, subdir)
1134 if os.path.exists(libdir_candidate):
1135 libdir = libdir_candidate
1141 remove_signing_keys(sdir)
1142 remove_debuggable_flags(sdir)
1146 if srclib["Prepare"]:
1147 cmd = replace_config_vars(srclib["Prepare"], None)
1149 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1150 if p.returncode != 0:
1151 raise BuildException("Error running prepare command for srclib %s"
1157 return (name, number, libdir)
1160 # Prepare the source code for a particular build
1161 # 'vcs' - the appropriate vcs object for the application
1162 # 'app' - the application details from the metadata
1163 # 'build' - the build details from the metadata
1164 # 'build_dir' - the path to the build directory, usually
1166 # 'srclib_dir' - the path to the source libraries directory, usually
1168 # 'extlib_dir' - the path to the external libraries directory, usually
1170 # Returns the (root, srclibpaths) where:
1171 # 'root' is the root directory, which may be the same as 'build_dir' or may
1172 # be a subdirectory of it.
1173 # 'srclibpaths' is information on the srclibs being used
1174 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1176 # Optionally, the actual app source can be in a subdirectory
1178 root_dir = os.path.join(build_dir, build['subdir'])
1180 root_dir = build_dir
1182 # Get a working copy of the right revision
1183 logging.info("Getting source for revision " + build['commit'])
1184 vcs.gotorevision(build['commit'])
1186 # Initialise submodules if required
1187 if build['submodules']:
1188 logging.info("Initialising submodules")
1189 vcs.initsubmodules()
1191 # Check that a subdir (if we're using one) exists. This has to happen
1192 # after the checkout, since it might not exist elsewhere
1193 if not os.path.exists(root_dir):
1194 raise BuildException('Missing subdir ' + root_dir)
1196 # Run an init command if one is required
1198 cmd = replace_config_vars(build['init'], build)
1199 logging.info("Running 'init' commands in %s" % root_dir)
1201 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1202 if p.returncode != 0:
1203 raise BuildException("Error running init command for %s:%s" %
1204 (app['id'], build['version']), p.output)
1206 # Apply patches if any
1208 logging.info("Applying patches")
1209 for patch in build['patch']:
1210 patch = patch.strip()
1211 logging.info("Applying " + patch)
1212 patch_path = os.path.join('metadata', app['id'], patch)
1213 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1214 if p.returncode != 0:
1215 raise BuildException("Failed to apply patch %s" % patch_path)
1217 # Get required source libraries
1219 if build['srclibs']:
1220 logging.info("Collecting source libraries")
1221 for lib in build['srclibs']:
1222 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver))
1224 for name, number, libpath in srclibpaths:
1225 place_srclib(root_dir, int(number) if number else None, libpath)
1227 basesrclib = vcs.getsrclib()
1228 # If one was used for the main source, add that too.
1230 srclibpaths.append(basesrclib)
1232 # Update the local.properties file
1233 localprops = [os.path.join(build_dir, 'local.properties')]
1235 localprops += [os.path.join(root_dir, 'local.properties')]
1236 for path in localprops:
1238 if os.path.isfile(path):
1239 logging.info("Updating local.properties file at %s" % path)
1245 logging.info("Creating local.properties file at %s" % path)
1246 # Fix old-fashioned 'sdk-location' by copying
1247 # from sdk.dir, if necessary
1248 if build['oldsdkloc']:
1249 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1250 re.S | re.M).group(1)
1251 props += "sdk-location=%s\n" % sdkloc
1253 props += "sdk.dir=%s\n" % config['sdk_path']
1254 props += "sdk-location=%s\n" % config['sdk_path']
1255 if build['ndk_path']:
1257 props += "ndk.dir=%s\n" % build['ndk_path']
1258 props += "ndk-location=%s\n" % build['ndk_path']
1259 # Add java.encoding if necessary
1260 if build['encoding']:
1261 props += "java.encoding=%s\n" % build['encoding']
1267 if build['type'] == 'gradle':
1268 flavours = build['gradle']
1270 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1271 gradlepluginver = None
1273 gradle_dirs = [root_dir]
1275 # Parent dir build.gradle
1276 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1277 if parent_dir.startswith(build_dir):
1278 gradle_dirs.append(parent_dir)
1280 for dir_path in gradle_dirs:
1283 if not os.path.isdir(dir_path):
1285 for filename in os.listdir(dir_path):
1286 if not filename.endswith('.gradle'):
1288 path = os.path.join(dir_path, filename)
1289 if not os.path.isfile(path):
1291 for line in file(path):
1292 match = version_regex.match(line)
1294 gradlepluginver = match.group(1)
1298 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1300 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1301 build['gradlepluginver'] = LooseVersion('0.11')
1304 n = build["target"].split('-')[1]
1305 FDroidPopen(['sed', '-i',
1306 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1307 'build.gradle'], cwd=root_dir, output=False)
1309 # Remove forced debuggable flags
1310 remove_debuggable_flags(root_dir)
1312 # Insert version code and number into the manifest if necessary
1313 if build['forceversion']:
1314 logging.info("Changing the version name")
1315 for path in manifest_paths(root_dir, flavours):
1316 if not os.path.isfile(path):
1318 if has_extension(path, 'xml'):
1319 p = FDroidPopen(['sed', '-i',
1320 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1321 path], output=False)
1322 if p.returncode != 0:
1323 raise BuildException("Failed to amend manifest")
1324 elif has_extension(path, 'gradle'):
1325 p = FDroidPopen(['sed', '-i',
1326 's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1327 path], output=False)
1328 if p.returncode != 0:
1329 raise BuildException("Failed to amend build.gradle")
1330 if build['forcevercode']:
1331 logging.info("Changing the version code")
1332 for path in manifest_paths(root_dir, flavours):
1333 if not os.path.isfile(path):
1335 if has_extension(path, 'xml'):
1336 p = FDroidPopen(['sed', '-i',
1337 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1338 path], output=False)
1339 if p.returncode != 0:
1340 raise BuildException("Failed to amend manifest")
1341 elif has_extension(path, 'gradle'):
1342 p = FDroidPopen(['sed', '-i',
1343 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1344 path], output=False)
1345 if p.returncode != 0:
1346 raise BuildException("Failed to amend build.gradle")
1348 # Delete unwanted files
1350 logging.info("Removing specified files")
1351 for part in getpaths(build_dir, build, 'rm'):
1352 dest = os.path.join(build_dir, part)
1353 logging.info("Removing {0}".format(part))
1354 if os.path.lexists(dest):
1355 if os.path.islink(dest):
1356 FDroidPopen(['unlink', dest], output=False)
1358 FDroidPopen(['rm', '-rf', dest], output=False)
1360 logging.info("...but it didn't exist")
1362 remove_signing_keys(build_dir)
1364 # Add required external libraries
1365 if build['extlibs']:
1366 logging.info("Collecting prebuilt libraries")
1367 libsdir = os.path.join(root_dir, 'libs')
1368 if not os.path.exists(libsdir):
1370 for lib in build['extlibs']:
1372 logging.info("...installing extlib {0}".format(lib))
1373 libf = os.path.basename(lib)
1374 libsrc = os.path.join(extlib_dir, lib)
1375 if not os.path.exists(libsrc):
1376 raise BuildException("Missing extlib file {0}".format(libsrc))
1377 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1379 # Run a pre-build command if one is required
1380 if build['prebuild']:
1381 logging.info("Running 'prebuild' commands in %s" % root_dir)
1383 cmd = replace_config_vars(build['prebuild'], build)
1385 # Substitute source library paths into prebuild commands
1386 for name, number, libpath in srclibpaths:
1387 libpath = os.path.relpath(libpath, root_dir)
1388 cmd = cmd.replace('$$' + name + '$$', libpath)
1390 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1391 if p.returncode != 0:
1392 raise BuildException("Error running prebuild command for %s:%s" %
1393 (app['id'], build['version']), p.output)
1395 # Generate (or update) the ant build file, build.xml...
1396 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1397 parms = ['android', 'update', 'lib-project']
1398 lparms = ['android', 'update', 'project']
1401 parms += ['-t', build['target']]
1402 lparms += ['-t', build['target']]
1403 if build['update'] == ['auto']:
1404 update_dirs = ant_subprojects(root_dir) + ['.']
1406 update_dirs = build['update']
1408 for d in update_dirs:
1409 subdir = os.path.join(root_dir, d)
1411 logging.debug("Updating main project")
1412 cmd = parms + ['-p', d]
1414 logging.debug("Updating subproject %s" % d)
1415 cmd = lparms + ['-p', d]
1416 p = SdkToolsPopen(cmd, cwd=root_dir)
1417 # Check to see whether an error was returned without a proper exit
1418 # code (this is the case for the 'no target set or target invalid'
1420 if p.returncode != 0 or p.output.startswith("Error: "):
1421 raise BuildException("Failed to update project at %s" % d, p.output)
1422 # Clean update dirs via ant
1424 logging.info("Cleaning subproject %s" % d)
1425 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1427 return (root_dir, srclibpaths)
1430 # Split and extend via globbing the paths from a field
1431 def getpaths(build_dir, build, field):
1433 for p in build[field]:
1435 full_path = os.path.join(build_dir, p)
1436 full_path = os.path.normpath(full_path)
1437 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1441 # Scan the source code in the given directory (and all subdirectories)
1442 # and return the number of fatal problems encountered
1443 def scan_source(build_dir, root_dir, thisbuild):
1447 # Common known non-free blobs (always lower case):
1449 re.compile(r'.*flurryagent', re.IGNORECASE),
1450 re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1451 re.compile(r'.*google.*analytics', re.IGNORECASE),
1452 re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1453 re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1454 re.compile(r'.*google.*admob', re.IGNORECASE),
1455 re.compile(r'.*google.*play.*services', re.IGNORECASE),
1456 re.compile(r'.*crittercism', re.IGNORECASE),
1457 re.compile(r'.*heyzap', re.IGNORECASE),
1458 re.compile(r'.*jpct.*ae', re.IGNORECASE),
1459 re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1460 re.compile(r'.*bugsense', re.IGNORECASE),
1461 re.compile(r'.*crashlytics', re.IGNORECASE),
1462 re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1463 re.compile(r'.*libspen23', re.IGNORECASE),
1466 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1467 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1469 scanignore_worked = set()
1470 scandelete_worked = set()
1473 ms = magic.open(magic.MIME_TYPE)
1475 except AttributeError:
1479 for p in scanignore:
1480 if fd.startswith(p):
1481 scanignore_worked.add(p)
1486 for p in scandelete:
1487 if fd.startswith(p):
1488 scandelete_worked.add(p)
1492 def ignoreproblem(what, fd, fp):
1493 logging.info('Ignoring %s at %s' % (what, fd))
1496 def removeproblem(what, fd, fp):
1497 logging.info('Removing %s at %s' % (what, fd))
1501 def warnproblem(what, fd):
1502 logging.warn('Found %s at %s' % (what, fd))
1504 def handleproblem(what, fd, fp):
1506 return ignoreproblem(what, fd, fp)
1508 return removeproblem(what, fd, fp)
1509 logging.error('Found %s at %s' % (what, fd))
1512 # Iterate through all files in the source code
1513 for r, d, f in os.walk(build_dir, topdown=True):
1515 # It's topdown, so checking the basename is enough
1516 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1522 # Path (relative) to the file
1523 fp = os.path.join(r, curfile)
1524 fd = fp[len(build_dir) + 1:]
1527 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1528 except UnicodeError:
1529 warnproblem('malformed magic number', fd)
1531 if mime == 'application/x-sharedlib':
1532 count += handleproblem('shared library', fd, fp)
1534 elif mime == 'application/x-archive':
1535 count += handleproblem('static library', fd, fp)
1537 elif mime == 'application/x-executable':
1538 count += handleproblem('binary executable', fd, fp)
1540 elif mime == 'application/x-java-applet':
1541 count += handleproblem('Java compiled class', fd, fp)
1546 'application/java-archive',
1547 'application/octet-stream',
1550 if has_extension(fp, 'apk'):
1551 removeproblem('APK file', fd, fp)
1553 elif has_extension(fp, 'jar'):
1555 if any(suspect.match(curfile) for suspect in usual_suspects):
1556 count += handleproblem('usual supect', fd, fp)
1558 warnproblem('JAR file', fd)
1560 elif has_extension(fp, 'zip'):
1561 warnproblem('ZIP file', fd)
1564 warnproblem('unknown compressed or binary file', fd)
1566 elif has_extension(fp, 'java'):
1567 if not os.path.isfile(fp):
1569 for line in file(fp):
1570 if 'DexClassLoader' in line:
1571 count += handleproblem('DexClassLoader', fd, fp)
1574 elif has_extension(fp, 'gradle'):
1575 if not os.path.isfile(fp):
1577 for i, line in enumerate(file(fp)):
1578 if any(suspect.match(line) for suspect in usual_suspects):
1579 count += handleproblem('usual suspect at line %d' % i, fd, fp)
1584 for p in scanignore:
1585 if p not in scanignore_worked:
1586 logging.error('Unused scanignore path: %s' % p)
1589 for p in scandelete:
1590 if p not in scandelete_worked:
1591 logging.error('Unused scandelete path: %s' % p)
1594 # Presence of a jni directory without buildjni=yes might
1595 # indicate a problem (if it's not a problem, explicitly use
1596 # buildjni=no to bypass this check)
1597 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1598 not thisbuild['buildjni']):
1599 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1608 self.path = os.path.join('stats', 'known_apks.txt')
1610 if os.path.isfile(self.path):
1611 for line in file(self.path):
1612 t = line.rstrip().split(' ')
1614 self.apks[t[0]] = (t[1], None)
1616 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1617 self.changed = False
1619 def writeifchanged(self):
1621 if not os.path.exists('stats'):
1623 f = open(self.path, 'w')
1625 for apk, app in self.apks.iteritems():
1627 line = apk + ' ' + appid
1629 line += ' ' + time.strftime('%Y-%m-%d', added)
1631 for line in sorted(lst):
1632 f.write(line + '\n')
1635 # Record an apk (if it's new, otherwise does nothing)
1636 # Returns the date it was added.
1637 def recordapk(self, apk, app):
1638 if apk not in self.apks:
1639 self.apks[apk] = (app, time.gmtime(time.time()))
1641 _, added = self.apks[apk]
1644 # Look up information - given the 'apkname', returns (app id, date added/None).
1645 # Or returns None for an unknown apk.
1646 def getapp(self, apkname):
1647 if apkname in self.apks:
1648 return self.apks[apkname]
1651 # Get the most recent 'num' apps added to the repo, as a list of package ids
1652 # with the most recent first.
1653 def getlatest(self, num):
1655 for apk, app in self.apks.iteritems():
1659 if apps[appid] > added:
1663 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1664 lst = [app for app, _ in sortedapps]
1669 def isApkDebuggable(apkfile, config):
1670 """Returns True if the given apk file is debuggable
1672 :param apkfile: full path to the apk to check"""
1674 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1676 if p.returncode != 0:
1677 logging.critical("Failed to get apk manifest information")
1679 for line in p.output.splitlines():
1680 if 'android:debuggable' in line and not line.endswith('0x0'):
1685 class AsynchronousFileReader(threading.Thread):
1688 Helper class to implement asynchronous reading of a file
1689 in a separate thread. Pushes read lines on a queue to
1690 be consumed in another thread.
1693 def __init__(self, fd, queue):
1694 assert isinstance(queue, Queue.Queue)
1695 assert callable(fd.readline)
1696 threading.Thread.__init__(self)
1701 '''The body of the tread: read lines and put them on the queue.'''
1702 for line in iter(self._fd.readline, ''):
1703 self._queue.put(line)
1706 '''Check whether there is no more content to expect.'''
1707 return not self.is_alive() and self._queue.empty()
1715 def SdkToolsPopen(commands, cwd=None, output=True):
1717 if cmd not in config:
1718 config[cmd] = find_sdk_tools_cmd(commands[0])
1719 return FDroidPopen([config[cmd]] + commands[1:],
1720 cwd=cwd, output=output)
1723 def FDroidPopen(commands, cwd=None, output=True):
1725 Run a command and capture the possibly huge output.
1727 :param commands: command and argument list like in subprocess.Popen
1728 :param cwd: optionally specifies a working directory
1729 :returns: A PopenResult.
1735 cwd = os.path.normpath(cwd)
1736 logging.debug("Directory: %s" % cwd)
1737 logging.debug("> %s" % ' '.join(commands))
1739 result = PopenResult()
1742 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1743 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1745 raise BuildException("OSError while trying to execute " +
1746 ' '.join(commands) + ': ' + str(e))
1748 stdout_queue = Queue.Queue()
1749 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1750 stdout_reader.start()
1752 # Check the queue for output (until there is no more to get)
1753 while not stdout_reader.eof():
1754 while not stdout_queue.empty():
1755 line = stdout_queue.get()
1756 if output and options.verbose:
1757 # Output directly to console
1758 sys.stderr.write(line)
1760 result.output += line
1764 result.returncode = p.wait()
1768 def remove_signing_keys(build_dir):
1769 comment = re.compile(r'[ ]*//')
1770 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1772 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1773 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1774 re.compile(r'.*variant\.outputFile = .*'),
1775 re.compile(r'.*output\.outputFile = .*'),
1776 re.compile(r'.*\.readLine\(.*'),
1778 for root, dirs, files in os.walk(build_dir):
1779 if 'build.gradle' in files:
1780 path = os.path.join(root, 'build.gradle')
1782 with open(path, "r") as o:
1783 lines = o.readlines()
1789 with open(path, "w") as o:
1790 while i < len(lines):
1793 while line.endswith('\\\n'):
1794 line = line.rstrip('\\\n') + lines[i]
1797 if comment.match(line):
1801 opened += line.count('{')
1802 opened -= line.count('}')
1805 if signing_configs.match(line):
1810 if any(s.match(line) for s in line_matches):
1818 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1821 'project.properties',
1823 'default.properties',
1824 'ant.properties', ]:
1825 if propfile in files:
1826 path = os.path.join(root, propfile)
1828 with open(path, "r") as o:
1829 lines = o.readlines()
1833 with open(path, "w") as o:
1835 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1842 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1845 def reset_env_path():
1846 global env, orig_path
1847 env['PATH'] = orig_path
1850 def add_to_env_path(path):
1852 paths = env['PATH'].split(os.pathsep)
1856 env['PATH'] = os.pathsep.join(paths)
1859 def replace_config_vars(cmd, build):
1861 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1862 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1863 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1864 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1865 if build is not None:
1866 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1867 cmd = cmd.replace('$$VERSION$$', build['version'])
1868 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1872 def place_srclib(root_dir, number, libpath):
1875 relpath = os.path.relpath(libpath, root_dir)
1876 proppath = os.path.join(root_dir, 'project.properties')
1879 if os.path.isfile(proppath):
1880 with open(proppath, "r") as o:
1881 lines = o.readlines()
1883 with open(proppath, "w") as o:
1886 if line.startswith('android.library.reference.%d=' % number):
1887 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1892 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1895 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1896 """Verify that two apks are the same
1898 One of the inputs is signed, the other is unsigned. The signature metadata
1899 is transferred from the signed to the unsigned apk, and then jarsigner is
1900 used to verify that the signature from the signed apk is also varlid for
1902 :param signed_apk: Path to a signed apk file
1903 :param unsigned_apk: Path to an unsigned apk file expected to match it
1904 :param tmp_dir: Path to directory for temporary files
1905 :returns: None if the verification is successful, otherwise a string
1906 describing what went wrong.
1908 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1909 with ZipFile(signed_apk) as signed_apk_as_zip:
1910 meta_inf_files = ['META-INF/MANIFEST.MF']
1911 for f in signed_apk_as_zip.namelist():
1912 if sigfile.match(f):
1913 meta_inf_files.append(f)
1914 if len(meta_inf_files) < 3:
1915 return "Signature files missing from {0}".format(signed_apk)
1916 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1917 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1918 for meta_inf_file in meta_inf_files:
1919 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1921 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1922 logging.info("...NOT verified - {0}".format(signed_apk))
1923 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1924 logging.info("...successfully verified")
1928 def compare_apks(apk1, apk2, tmp_dir):
1931 Returns None if the apk content is the same (apart from the signing key),
1932 otherwise a string describing what's different, or what went wrong when
1933 trying to do the comparison.
1936 badchars = re.compile('''[/ :;'"]''')
1937 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1938 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1939 for d in [apk1dir, apk2dir]:
1940 if os.path.exists(d):
1943 os.mkdir(os.path.join(d, 'jar-xf'))
1945 if subprocess.call(['jar', 'xf',
1946 os.path.abspath(apk1)],
1947 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1948 return("Failed to unpack " + apk1)
1949 if subprocess.call(['jar', 'xf',
1950 os.path.abspath(apk2)],
1951 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1952 return("Failed to unpack " + apk2)
1954 # try to find apktool in the path, if it hasn't been manually configed
1955 if 'apktool' not in config:
1956 tmp = find_command('apktool')
1958 config['apktool'] = tmp
1959 if 'apktool' in config:
1960 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1962 return("Failed to unpack " + apk1)
1963 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1965 return("Failed to unpack " + apk2)
1967 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1968 lines = p.output.splitlines()
1969 if len(lines) != 1 or 'META-INF' not in lines[0]:
1970 meld = find_command('meld')
1971 if meld is not None:
1972 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1973 return("Unexpected diff output - " + p.output)
1975 # since everything verifies, delete the comparison to keep cruft down
1976 shutil.rmtree(apk1dir)
1977 shutil.rmtree(apk2dir)
1979 # If we get here, it seems like they're the same!
1983 def find_command(command):
1984 '''find the full path of a command, or None if it can't be found in the PATH'''
1987 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1989 fpath, fname = os.path.split(command)
1994 for path in os.environ["PATH"].split(os.pathsep):
1995 path = path.strip('"')
1996 exe_file = os.path.join(path, command)
1997 if is_exe(exe_file):
2004 '''generate a random password for when generating keys'''
2005 h = hashlib.sha256()
2006 h.update(os.urandom(16)) # salt
2007 h.update(bytes(socket.getfqdn()))
2008 return h.digest().encode('base64').strip()
2011 def genkeystore(localconfig):
2012 '''Generate a new key with random passwords and add it to new keystore'''
2013 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2014 keystoredir = os.path.dirname(localconfig['keystore'])
2015 if keystoredir is None or keystoredir == '':
2016 keystoredir = os.path.join(os.getcwd(), keystoredir)
2017 if not os.path.exists(keystoredir):
2018 os.makedirs(keystoredir, mode=0o700)
2020 write_password_file("keystorepass", localconfig['keystorepass'])
2021 write_password_file("keypass", localconfig['keypass'])
2022 p = FDroidPopen(['keytool', '-genkey',
2023 '-keystore', localconfig['keystore'],
2024 '-alias', localconfig['repo_keyalias'],
2025 '-keyalg', 'RSA', '-keysize', '4096',
2026 '-sigalg', 'SHA256withRSA',
2027 '-validity', '10000',
2028 '-storepass:file', config['keystorepassfile'],
2029 '-keypass:file', config['keypassfile'],
2030 '-dname', localconfig['keydname']])
2031 # TODO keypass should be sent via stdin
2032 os.chmod(localconfig['keystore'], 0o0600)
2033 if p.returncode != 0:
2034 raise BuildException("Failed to generate key", p.output)
2035 # now show the lovely key that was just generated
2036 p = FDroidPopen(['keytool', '-list', '-v',
2037 '-keystore', localconfig['keystore'],
2038 '-alias', localconfig['repo_keyalias'],
2039 '-storepass:file', config['keystorepassfile']])
2040 logging.info(p.output.strip() + '\n\n')
2043 def write_to_config(thisconfig, key, value=None):
2044 '''write a key/value to the local config.py'''
2046 origkey = key + '_orig'
2047 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2048 with open('config.py', 'r') as f:
2050 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2051 repl = '\n' + key + ' = "' + value + '"'
2052 data = re.sub(pattern, repl, data)
2053 # if this key is not in the file, append it
2054 if not re.match('\s*' + key + '\s*=\s*"', data):
2056 # make sure the file ends with a carraige return
2057 if not re.match('\n$', data):
2059 with open('config.py', 'w') as f:
2063 def parse_xml(path):
2064 return XMLElementTree.parse(path).getroot()
2067 def string_is_integer(string):