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', 'reset', '--hard'], cwd=self.local, output=False)
571 if p.returncode != 0:
572 raise VCSException("Git reset failed", p.output)
573 # Remove untracked files now, in case they're tracked in the target
574 # revision (it happens!)
575 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
576 if p.returncode != 0:
577 raise VCSException("Git clean failed", p.output)
578 if not self.refreshed:
579 # Get latest commits and tags from remote
580 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
581 if p.returncode != 0:
582 raise VCSException("Git fetch failed", p.output)
583 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
584 if p.returncode != 0:
585 raise VCSException("Git fetch failed", p.output)
586 # Recreate origin/HEAD as git clone would do it, in case it disappeared
587 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
588 if p.returncode != 0:
589 lines = p.output.splitlines()
590 if 'Multiple remote HEAD branches' not in lines[0]:
591 raise VCSException("Git remote set-head failed", p.output)
592 branch = lines[1].split(' ')[-1]
593 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
594 if p2.returncode != 0:
595 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
596 self.refreshed = True
597 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
598 # a github repo. Most of the time this is the same as origin/master.
599 rev = rev or 'origin/HEAD'
600 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
601 if p.returncode != 0:
602 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
603 # Get rid of any uncontrolled files left behind
604 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
605 if p.returncode != 0:
606 raise VCSException("Git clean failed", p.output)
608 def initsubmodules(self):
610 submfile = os.path.join(self.local, '.gitmodules')
611 if not os.path.isfile(submfile):
612 raise VCSException("No git submodules available")
614 # fix submodules not accessible without an account and public key auth
615 with open(submfile, 'r') as f:
616 lines = f.readlines()
617 with open(submfile, 'w') as f:
619 if 'git@github.com' in line:
620 line = line.replace('git@github.com:', 'https://github.com/')
624 ['git', 'reset', '--hard'],
625 ['git', 'clean', '-dffx'],
627 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
628 if p.returncode != 0:
629 raise VCSException("Git submodule reset failed", p.output)
630 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
631 if p.returncode != 0:
632 raise VCSException("Git submodule sync failed", p.output)
633 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
634 if p.returncode != 0:
635 raise VCSException("Git submodule update failed", p.output)
639 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
640 return p.output.splitlines()
642 def latesttags(self, tags, number):
647 ['git', 'show', '--format=format:%ct', '-s', tag],
648 cwd=self.local, output=False)
649 # Timestamp is on the last line. For a normal tag, it's the only
650 # line, but for annotated tags, the rest of the info precedes it.
651 ts = int(p.output.splitlines()[-1])
654 for _, t in sorted(tl)[-number:]:
659 class vcs_gitsvn(vcs):
664 # If the local directory exists, but is somehow not a git repository, git
665 # will traverse up the directory tree until it finds one that is (i.e.
666 # fdroidserver) and then we'll proceed to destory it! This is called as
669 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
670 result = p.output.rstrip()
671 if not result.endswith(self.local):
672 raise VCSException('Repository mismatch')
674 def gotorevisionx(self, rev):
675 if not os.path.exists(self.local):
677 gitsvn_args = ['git', 'svn', 'clone']
678 if ';' in self.remote:
679 remote_split = self.remote.split(';')
680 for i in remote_split[1:]:
681 if i.startswith('trunk='):
682 gitsvn_args.extend(['-T', i[6:]])
683 elif i.startswith('tags='):
684 gitsvn_args.extend(['-t', i[5:]])
685 elif i.startswith('branches='):
686 gitsvn_args.extend(['-b', i[9:]])
687 gitsvn_args.extend([remote_split[0], self.local])
688 p = FDroidPopen(gitsvn_args, output=False)
689 if p.returncode != 0:
690 self.clone_failed = True
691 raise VCSException("Git svn clone failed", p.output)
693 gitsvn_args.extend([self.remote, self.local])
694 p = FDroidPopen(gitsvn_args, output=False)
695 if p.returncode != 0:
696 self.clone_failed = True
697 raise VCSException("Git svn clone failed", p.output)
701 # Discard any working tree changes
702 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
703 if p.returncode != 0:
704 raise VCSException("Git reset failed", p.output)
705 # Remove untracked files now, in case they're tracked in the target
706 # revision (it happens!)
707 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
708 if p.returncode != 0:
709 raise VCSException("Git clean failed", p.output)
710 if not self.refreshed:
711 # Get new commits, branches and tags from repo
712 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
713 if p.returncode != 0:
714 raise VCSException("Git svn fetch failed")
715 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
716 if p.returncode != 0:
717 raise VCSException("Git svn rebase failed", p.output)
718 self.refreshed = True
720 rev = rev or 'master'
722 nospaces_rev = rev.replace(' ', '%20')
723 # Try finding a svn tag
724 for treeish in ['origin/', '']:
725 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
726 if p.returncode == 0:
728 if p.returncode != 0:
729 # No tag found, normal svn rev translation
730 # Translate svn rev into git format
731 rev_split = rev.split('/')
734 for treeish in ['origin/', '']:
735 if len(rev_split) > 1:
736 treeish += rev_split[0]
737 svn_rev = rev_split[1]
740 # if no branch is specified, then assume trunk (i.e. 'master' branch):
744 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
746 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
747 git_rev = p.output.rstrip()
749 if p.returncode == 0 and git_rev:
752 if p.returncode != 0 or not git_rev:
753 # Try a plain git checkout as a last resort
754 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
755 if p.returncode != 0:
756 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
758 # Check out the git rev equivalent to the svn rev
759 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
760 if p.returncode != 0:
761 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
763 # Get rid of any uncontrolled files left behind
764 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
765 if p.returncode != 0:
766 raise VCSException("Git clean failed", p.output)
770 for treeish in ['origin/', '']:
771 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
777 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
778 if p.returncode != 0:
780 return p.output.strip()
788 def gotorevisionx(self, rev):
789 if not os.path.exists(self.local):
790 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
791 if p.returncode != 0:
792 self.clone_failed = True
793 raise VCSException("Hg clone failed", p.output)
795 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
796 if p.returncode != 0:
797 raise VCSException("Hg status failed", p.output)
798 for line in p.output.splitlines():
799 if not line.startswith('? '):
800 raise VCSException("Unexpected output from hg status -uS: " + line)
801 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
802 if not self.refreshed:
803 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
804 if p.returncode != 0:
805 raise VCSException("Hg pull failed", p.output)
806 self.refreshed = True
808 rev = rev or 'default'
811 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
812 if p.returncode != 0:
813 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
814 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
815 # Also delete untracked files, we have to enable purge extension for that:
816 if "'purge' is provided by the following extension" in p.output:
817 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
818 myfile.write("\n[extensions]\nhgext.purge=\n")
819 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
820 if p.returncode != 0:
821 raise VCSException("HG purge failed", p.output)
822 elif p.returncode != 0:
823 raise VCSException("HG purge failed", p.output)
826 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
827 return p.output.splitlines()[1:]
835 def gotorevisionx(self, rev):
836 if not os.path.exists(self.local):
837 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
838 if p.returncode != 0:
839 self.clone_failed = True
840 raise VCSException("Bzr branch failed", p.output)
842 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
843 if p.returncode != 0:
844 raise VCSException("Bzr revert failed", p.output)
845 if not self.refreshed:
846 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
847 if p.returncode != 0:
848 raise VCSException("Bzr update failed", p.output)
849 self.refreshed = True
851 revargs = list(['-r', rev] if rev else [])
852 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
853 if p.returncode != 0:
854 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
857 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
858 return [tag.split(' ')[0].strip() for tag in
859 p.output.splitlines()]
862 def unescape_string(string):
863 if string[0] == '"' and string[-1] == '"':
866 return string.replace("\\'", "'")
869 def retrieve_string(app_dir, string, xmlfiles=None):
874 os.path.join(app_dir, 'res'),
875 os.path.join(app_dir, 'src', 'main', 'res'),
877 for r, d, f in os.walk(res_dir):
878 if os.path.basename(r) == 'values':
879 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
881 if not string.startswith('@string/'):
882 return unescape_string(string)
884 name = string[len('@string/'):]
886 for path in xmlfiles:
887 if not os.path.isfile(path):
889 xml = parse_xml(path)
890 element = xml.find('string[@name="' + name + '"]')
891 if element is not None and element.text is not None:
892 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
897 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
898 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
901 # Return list of existing files that will be used to find the highest vercode
902 def manifest_paths(app_dir, flavours):
904 possible_manifests = \
905 [os.path.join(app_dir, 'AndroidManifest.xml'),
906 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
907 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
908 os.path.join(app_dir, 'build.gradle')]
910 for flavour in flavours:
913 possible_manifests.append(
914 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
916 return [path for path in possible_manifests if os.path.isfile(path)]
919 # Retrieve the package name. Returns the name, or None if not found.
920 def fetch_real_name(app_dir, flavours):
921 for path in manifest_paths(app_dir, flavours):
922 if not has_extension(path, 'xml') or not os.path.isfile(path):
924 logging.debug("fetch_real_name: Checking manifest at " + path)
925 xml = parse_xml(path)
926 app = xml.find('application')
927 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
929 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
930 result = retrieve_string_singleline(app_dir, label)
932 result = result.strip()
937 def get_library_references(root_dir):
939 proppath = os.path.join(root_dir, 'project.properties')
940 if not os.path.isfile(proppath):
942 for line in file(proppath):
943 if not line.startswith('android.library.reference.'):
945 path = line.split('=')[1].strip()
946 relpath = os.path.join(root_dir, path)
947 if not os.path.isdir(relpath):
949 logging.debug("Found subproject at %s" % path)
950 libraries.append(path)
954 def ant_subprojects(root_dir):
955 subprojects = get_library_references(root_dir)
956 for subpath in subprojects:
957 subrelpath = os.path.join(root_dir, subpath)
958 for p in get_library_references(subrelpath):
959 relp = os.path.normpath(os.path.join(subpath, p))
960 if relp not in subprojects:
961 subprojects.insert(0, relp)
965 def remove_debuggable_flags(root_dir):
966 # Remove forced debuggable flags
967 logging.debug("Removing debuggable flags from %s" % root_dir)
968 for root, dirs, files in os.walk(root_dir):
969 if 'AndroidManifest.xml' in files:
970 path = os.path.join(root, 'AndroidManifest.xml')
971 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
972 if p.returncode != 0:
973 raise BuildException("Failed to remove debuggable flags of %s" % path)
976 # Extract some information from the AndroidManifest.xml at the given path.
977 # Returns (version, vercode, package), any or all of which might be None.
978 # All values returned are strings.
979 def parse_androidmanifests(paths, ignoreversions=None):
982 return (None, None, None)
984 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
985 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
986 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
988 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
996 if not os.path.isfile(path):
999 logging.debug("Parsing manifest at {0}".format(path))
1000 gradle = has_extension(path, 'gradle')
1003 # Remember package name, may be defined separately from version+vercode
1004 package = max_package
1007 for line in file(path):
1009 matches = psearch_g(line)
1011 package = matches.group(1)
1013 matches = vnsearch_g(line)
1015 version = matches.group(2)
1017 matches = vcsearch_g(line)
1019 vercode = matches.group(1)
1021 xml = parse_xml(path)
1022 if "package" in xml.attrib:
1023 package = xml.attrib["package"].encode('utf-8')
1024 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1025 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1026 base_dir = os.path.dirname(path)
1027 version = retrieve_string_singleline(base_dir, version)
1028 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1029 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1030 if string_is_integer(a):
1033 logging.debug("..got package={0}, version={1}, vercode={2}"
1034 .format(package, version, vercode))
1036 # Always grab the package name and version name in case they are not
1037 # together with the highest version code
1038 if max_package is None and package is not None:
1039 max_package = package
1040 if max_version is None and version is not None:
1041 max_version = version
1043 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1044 if not ignoresearch or not ignoresearch(version):
1045 if version is not None:
1046 max_version = version
1047 if vercode is not None:
1048 max_vercode = vercode
1049 if package is not None:
1050 max_package = package
1052 max_version = "Ignore"
1054 if max_version is None:
1055 max_version = "Unknown"
1057 if max_package and not is_valid_package_name(max_package):
1058 raise FDroidException("Invalid package name {0}".format(max_package))
1060 return (max_version, max_vercode, max_package)
1063 def is_valid_package_name(name):
1064 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1067 class FDroidException(Exception):
1069 def __init__(self, value, detail=None):
1071 self.detail = detail
1073 def get_wikitext(self):
1074 ret = repr(self.value) + "\n"
1078 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1086 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1090 class VCSException(FDroidException):
1094 class BuildException(FDroidException):
1098 # Get the specified source library.
1099 # Returns the path to it. Normally this is the path to be used when referencing
1100 # it, which may be a subdirectory of the actual project. If you want the base
1101 # directory of the project, pass 'basepath=True'.
1102 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1103 raw=False, prepare=True, preponly=False):
1111 name, ref = spec.split('@')
1113 number, name = name.split(':', 1)
1115 name, subdir = name.split('/', 1)
1117 if name not in metadata.srclibs:
1118 raise VCSException('srclib ' + name + ' not found.')
1120 srclib = metadata.srclibs[name]
1122 sdir = os.path.join(srclib_dir, name)
1125 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1126 vcs.srclib = (name, number, sdir)
1128 vcs.gotorevision(ref)
1135 libdir = os.path.join(sdir, subdir)
1136 elif srclib["Subdir"]:
1137 for subdir in srclib["Subdir"]:
1138 libdir_candidate = os.path.join(sdir, subdir)
1139 if os.path.exists(libdir_candidate):
1140 libdir = libdir_candidate
1146 remove_signing_keys(sdir)
1147 remove_debuggable_flags(sdir)
1151 if srclib["Prepare"]:
1152 cmd = replace_config_vars(srclib["Prepare"], None)
1154 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1155 if p.returncode != 0:
1156 raise BuildException("Error running prepare command for srclib %s"
1162 return (name, number, libdir)
1165 # Prepare the source code for a particular build
1166 # 'vcs' - the appropriate vcs object for the application
1167 # 'app' - the application details from the metadata
1168 # 'build' - the build details from the metadata
1169 # 'build_dir' - the path to the build directory, usually
1171 # 'srclib_dir' - the path to the source libraries directory, usually
1173 # 'extlib_dir' - the path to the external libraries directory, usually
1175 # Returns the (root, srclibpaths) where:
1176 # 'root' is the root directory, which may be the same as 'build_dir' or may
1177 # be a subdirectory of it.
1178 # 'srclibpaths' is information on the srclibs being used
1179 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1181 # Optionally, the actual app source can be in a subdirectory
1183 root_dir = os.path.join(build_dir, build['subdir'])
1185 root_dir = build_dir
1187 # Get a working copy of the right revision
1188 logging.info("Getting source for revision " + build['commit'])
1189 vcs.gotorevision(build['commit'])
1191 # Initialise submodules if requred
1192 if build['submodules']:
1193 logging.info("Initialising submodules")
1194 vcs.initsubmodules()
1196 # Check that a subdir (if we're using one) exists. This has to happen
1197 # after the checkout, since it might not exist elsewhere
1198 if not os.path.exists(root_dir):
1199 raise BuildException('Missing subdir ' + root_dir)
1201 # Run an init command if one is required
1203 cmd = replace_config_vars(build['init'], build)
1204 logging.info("Running 'init' commands in %s" % root_dir)
1206 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1207 if p.returncode != 0:
1208 raise BuildException("Error running init command for %s:%s" %
1209 (app['id'], build['version']), p.output)
1211 # Apply patches if any
1213 logging.info("Applying patches")
1214 for patch in build['patch']:
1215 patch = patch.strip()
1216 logging.info("Applying " + patch)
1217 patch_path = os.path.join('metadata', app['id'], patch)
1218 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1219 if p.returncode != 0:
1220 raise BuildException("Failed to apply patch %s" % patch_path)
1222 # Get required source libraries
1224 if build['srclibs']:
1225 logging.info("Collecting source libraries")
1226 for lib in build['srclibs']:
1227 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver))
1229 for name, number, libpath in srclibpaths:
1230 place_srclib(root_dir, int(number) if number else None, libpath)
1232 basesrclib = vcs.getsrclib()
1233 # If one was used for the main source, add that too.
1235 srclibpaths.append(basesrclib)
1237 # Update the local.properties file
1238 localprops = [os.path.join(build_dir, 'local.properties')]
1240 localprops += [os.path.join(root_dir, 'local.properties')]
1241 for path in localprops:
1243 if os.path.isfile(path):
1244 logging.info("Updating local.properties file at %s" % path)
1250 logging.info("Creating local.properties file at %s" % path)
1251 # Fix old-fashioned 'sdk-location' by copying
1252 # from sdk.dir, if necessary
1253 if build['oldsdkloc']:
1254 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1255 re.S | re.M).group(1)
1256 props += "sdk-location=%s\n" % sdkloc
1258 props += "sdk.dir=%s\n" % config['sdk_path']
1259 props += "sdk-location=%s\n" % config['sdk_path']
1260 if build['ndk_path']:
1262 props += "ndk.dir=%s\n" % build['ndk_path']
1263 props += "ndk-location=%s\n" % build['ndk_path']
1264 # Add java.encoding if necessary
1265 if build['encoding']:
1266 props += "java.encoding=%s\n" % build['encoding']
1272 if build['type'] == 'gradle':
1273 flavours = build['gradle']
1275 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1276 gradlepluginver = None
1278 gradle_dirs = [root_dir]
1280 # Parent dir build.gradle
1281 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1282 if parent_dir.startswith(build_dir):
1283 gradle_dirs.append(parent_dir)
1285 for dir_path in gradle_dirs:
1288 if not os.path.isdir(dir_path):
1290 for filename in os.listdir(dir_path):
1291 if not filename.endswith('.gradle'):
1293 path = os.path.join(dir_path, filename)
1294 if not os.path.isfile(path):
1296 for line in file(path):
1297 match = version_regex.match(line)
1299 gradlepluginver = match.group(1)
1303 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1305 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1306 build['gradlepluginver'] = LooseVersion('0.11')
1309 n = build["target"].split('-')[1]
1310 FDroidPopen(['sed', '-i',
1311 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1312 'build.gradle'], cwd=root_dir, output=False)
1314 # Remove forced debuggable flags
1315 remove_debuggable_flags(root_dir)
1317 # Insert version code and number into the manifest if necessary
1318 if build['forceversion']:
1319 logging.info("Changing the version name")
1320 for path in manifest_paths(root_dir, flavours):
1321 if not os.path.isfile(path):
1323 if has_extension(path, 'xml'):
1324 p = FDroidPopen(['sed', '-i',
1325 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1326 path], output=False)
1327 if p.returncode != 0:
1328 raise BuildException("Failed to amend manifest")
1329 elif has_extension(path, 'gradle'):
1330 p = FDroidPopen(['sed', '-i',
1331 's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1332 path], output=False)
1333 if p.returncode != 0:
1334 raise BuildException("Failed to amend build.gradle")
1335 if build['forcevercode']:
1336 logging.info("Changing the version code")
1337 for path in manifest_paths(root_dir, flavours):
1338 if not os.path.isfile(path):
1340 if has_extension(path, 'xml'):
1341 p = FDroidPopen(['sed', '-i',
1342 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1343 path], output=False)
1344 if p.returncode != 0:
1345 raise BuildException("Failed to amend manifest")
1346 elif has_extension(path, 'gradle'):
1347 p = FDroidPopen(['sed', '-i',
1348 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1349 path], output=False)
1350 if p.returncode != 0:
1351 raise BuildException("Failed to amend build.gradle")
1353 # Delete unwanted files
1355 logging.info("Removing specified files")
1356 for part in getpaths(build_dir, build, 'rm'):
1357 dest = os.path.join(build_dir, part)
1358 logging.info("Removing {0}".format(part))
1359 if os.path.lexists(dest):
1360 if os.path.islink(dest):
1361 FDroidPopen(['unlink', dest], output=False)
1363 FDroidPopen(['rm', '-rf', dest], output=False)
1365 logging.info("...but it didn't exist")
1367 remove_signing_keys(build_dir)
1369 # Add required external libraries
1370 if build['extlibs']:
1371 logging.info("Collecting prebuilt libraries")
1372 libsdir = os.path.join(root_dir, 'libs')
1373 if not os.path.exists(libsdir):
1375 for lib in build['extlibs']:
1377 logging.info("...installing extlib {0}".format(lib))
1378 libf = os.path.basename(lib)
1379 libsrc = os.path.join(extlib_dir, lib)
1380 if not os.path.exists(libsrc):
1381 raise BuildException("Missing extlib file {0}".format(libsrc))
1382 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1384 # Run a pre-build command if one is required
1385 if build['prebuild']:
1386 logging.info("Running 'prebuild' commands in %s" % root_dir)
1388 cmd = replace_config_vars(build['prebuild'], build)
1390 # Substitute source library paths into prebuild commands
1391 for name, number, libpath in srclibpaths:
1392 libpath = os.path.relpath(libpath, root_dir)
1393 cmd = cmd.replace('$$' + name + '$$', libpath)
1395 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1396 if p.returncode != 0:
1397 raise BuildException("Error running prebuild command for %s:%s" %
1398 (app['id'], build['version']), p.output)
1400 # Generate (or update) the ant build file, build.xml...
1401 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1402 parms = ['android', 'update', 'lib-project']
1403 lparms = ['android', 'update', 'project']
1406 parms += ['-t', build['target']]
1407 lparms += ['-t', build['target']]
1408 if build['update'] == ['auto']:
1409 update_dirs = ant_subprojects(root_dir) + ['.']
1411 update_dirs = build['update']
1413 for d in update_dirs:
1414 subdir = os.path.join(root_dir, d)
1416 logging.debug("Updating main project")
1417 cmd = parms + ['-p', d]
1419 logging.debug("Updating subproject %s" % d)
1420 cmd = lparms + ['-p', d]
1421 p = SdkToolsPopen(cmd, cwd=root_dir)
1422 # Check to see whether an error was returned without a proper exit
1423 # code (this is the case for the 'no target set or target invalid'
1425 if p.returncode != 0 or p.output.startswith("Error: "):
1426 raise BuildException("Failed to update project at %s" % d, p.output)
1427 # Clean update dirs via ant
1429 logging.info("Cleaning subproject %s" % d)
1430 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1432 return (root_dir, srclibpaths)
1435 # Split and extend via globbing the paths from a field
1436 def getpaths(build_dir, build, field):
1438 for p in build[field]:
1440 full_path = os.path.join(build_dir, p)
1441 full_path = os.path.normpath(full_path)
1442 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1446 # Scan the source code in the given directory (and all subdirectories)
1447 # and return the number of fatal problems encountered
1448 def scan_source(build_dir, root_dir, thisbuild):
1452 # Common known non-free blobs (always lower case):
1454 re.compile(r'flurryagent', re.IGNORECASE),
1455 re.compile(r'paypal.*mpl', re.IGNORECASE),
1456 re.compile(r'google.*analytics', re.IGNORECASE),
1457 re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1458 re.compile(r'google.*ad.*view', re.IGNORECASE),
1459 re.compile(r'google.*admob', re.IGNORECASE),
1460 re.compile(r'google.*play.*services', re.IGNORECASE),
1461 re.compile(r'crittercism', re.IGNORECASE),
1462 re.compile(r'heyzap', re.IGNORECASE),
1463 re.compile(r'jpct.*ae', re.IGNORECASE),
1464 re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1465 re.compile(r'bugsense', re.IGNORECASE),
1466 re.compile(r'crashlytics', re.IGNORECASE),
1467 re.compile(r'ouya.*sdk', re.IGNORECASE),
1468 re.compile(r'libspen23', re.IGNORECASE),
1471 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1472 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1474 scanignore_worked = set()
1475 scandelete_worked = set()
1478 ms = magic.open(magic.MIME_TYPE)
1480 except AttributeError:
1484 for p in scanignore:
1485 if fd.startswith(p):
1486 scanignore_worked.add(p)
1491 for p in scandelete:
1492 if fd.startswith(p):
1493 scandelete_worked.add(p)
1497 def ignoreproblem(what, fd, fp):
1498 logging.info('Ignoring %s at %s' % (what, fd))
1501 def removeproblem(what, fd, fp):
1502 logging.info('Removing %s at %s' % (what, fd))
1506 def warnproblem(what, fd):
1507 logging.warn('Found %s at %s' % (what, fd))
1509 def handleproblem(what, fd, fp):
1511 return ignoreproblem(what, fd, fp)
1513 return removeproblem(what, fd, fp)
1514 logging.error('Found %s at %s' % (what, fd))
1517 # Iterate through all files in the source code
1518 for r, d, f in os.walk(build_dir, topdown=True):
1520 # It's topdown, so checking the basename is enough
1521 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1527 # Path (relative) to the file
1528 fp = os.path.join(r, curfile)
1529 fd = fp[len(build_dir) + 1:]
1532 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1533 except UnicodeError:
1534 warnproblem('malformed magic number', fd)
1536 if mime == 'application/x-sharedlib':
1537 count += handleproblem('shared library', fd, fp)
1539 elif mime == 'application/x-archive':
1540 count += handleproblem('static library', fd, fp)
1542 elif mime == 'application/x-executable':
1543 count += handleproblem('binary executable', fd, fp)
1545 elif mime == 'application/x-java-applet':
1546 count += handleproblem('Java compiled class', fd, fp)
1551 'application/java-archive',
1552 'application/octet-stream',
1555 if has_extension(fp, 'apk'):
1556 removeproblem('APK file', fd, fp)
1558 elif has_extension(fp, 'jar'):
1560 if any(suspect.match(curfile) for suspect in usual_suspects):
1561 count += handleproblem('usual supect', fd, fp)
1563 warnproblem('JAR file', fd)
1565 elif has_extension(fp, 'zip'):
1566 warnproblem('ZIP file', fd)
1569 warnproblem('unknown compressed or binary file', fd)
1571 elif has_extension(fp, 'java') and os.path.isfile(fp):
1572 if not os.path.isfile(fp):
1574 for line in file(fp):
1575 if 'DexClassLoader' in line:
1576 count += handleproblem('DexClassLoader', fd, fp)
1581 for p in scanignore:
1582 if p not in scanignore_worked:
1583 logging.error('Unused scanignore path: %s' % p)
1586 for p in scandelete:
1587 if p not in scandelete_worked:
1588 logging.error('Unused scandelete path: %s' % p)
1591 # Presence of a jni directory without buildjni=yes might
1592 # indicate a problem (if it's not a problem, explicitly use
1593 # buildjni=no to bypass this check)
1594 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1595 not thisbuild['buildjni']):
1596 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1605 self.path = os.path.join('stats', 'known_apks.txt')
1607 if os.path.isfile(self.path):
1608 for line in file(self.path):
1609 t = line.rstrip().split(' ')
1611 self.apks[t[0]] = (t[1], None)
1613 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1614 self.changed = False
1616 def writeifchanged(self):
1618 if not os.path.exists('stats'):
1620 f = open(self.path, 'w')
1622 for apk, app in self.apks.iteritems():
1624 line = apk + ' ' + appid
1626 line += ' ' + time.strftime('%Y-%m-%d', added)
1628 for line in sorted(lst):
1629 f.write(line + '\n')
1632 # Record an apk (if it's new, otherwise does nothing)
1633 # Returns the date it was added.
1634 def recordapk(self, apk, app):
1635 if apk not in self.apks:
1636 self.apks[apk] = (app, time.gmtime(time.time()))
1638 _, added = self.apks[apk]
1641 # Look up information - given the 'apkname', returns (app id, date added/None).
1642 # Or returns None for an unknown apk.
1643 def getapp(self, apkname):
1644 if apkname in self.apks:
1645 return self.apks[apkname]
1648 # Get the most recent 'num' apps added to the repo, as a list of package ids
1649 # with the most recent first.
1650 def getlatest(self, num):
1652 for apk, app in self.apks.iteritems():
1656 if apps[appid] > added:
1660 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1661 lst = [app for app, _ in sortedapps]
1666 def isApkDebuggable(apkfile, config):
1667 """Returns True if the given apk file is debuggable
1669 :param apkfile: full path to the apk to check"""
1671 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1673 if p.returncode != 0:
1674 logging.critical("Failed to get apk manifest information")
1676 for line in p.output.splitlines():
1677 if 'android:debuggable' in line and not line.endswith('0x0'):
1682 class AsynchronousFileReader(threading.Thread):
1685 Helper class to implement asynchronous reading of a file
1686 in a separate thread. Pushes read lines on a queue to
1687 be consumed in another thread.
1690 def __init__(self, fd, queue):
1691 assert isinstance(queue, Queue.Queue)
1692 assert callable(fd.readline)
1693 threading.Thread.__init__(self)
1698 '''The body of the tread: read lines and put them on the queue.'''
1699 for line in iter(self._fd.readline, ''):
1700 self._queue.put(line)
1703 '''Check whether there is no more content to expect.'''
1704 return not self.is_alive() and self._queue.empty()
1712 def SdkToolsPopen(commands, cwd=None, output=True):
1714 if cmd not in config:
1715 config[cmd] = find_sdk_tools_cmd(commands[0])
1716 return FDroidPopen([config[cmd]] + commands[1:],
1717 cwd=cwd, output=output)
1720 def FDroidPopen(commands, cwd=None, output=True):
1722 Run a command and capture the possibly huge output.
1724 :param commands: command and argument list like in subprocess.Popen
1725 :param cwd: optionally specifies a working directory
1726 :returns: A PopenResult.
1732 cwd = os.path.normpath(cwd)
1733 logging.debug("Directory: %s" % cwd)
1734 logging.debug("> %s" % ' '.join(commands))
1736 result = PopenResult()
1739 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1740 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1742 raise BuildException("OSError while trying to execute " +
1743 ' '.join(commands) + ': ' + str(e))
1745 stdout_queue = Queue.Queue()
1746 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1747 stdout_reader.start()
1749 # Check the queue for output (until there is no more to get)
1750 while not stdout_reader.eof():
1751 while not stdout_queue.empty():
1752 line = stdout_queue.get()
1753 if output and options.verbose:
1754 # Output directly to console
1755 sys.stderr.write(line)
1757 result.output += line
1761 result.returncode = p.wait()
1765 def remove_signing_keys(build_dir):
1766 comment = re.compile(r'[ ]*//')
1767 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1769 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1770 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1771 re.compile(r'.*variant\.outputFile = .*'),
1772 re.compile(r'.*output\.outputFile = .*'),
1773 re.compile(r'.*\.readLine\(.*'),
1775 for root, dirs, files in os.walk(build_dir):
1776 if 'build.gradle' in files:
1777 path = os.path.join(root, 'build.gradle')
1779 with open(path, "r") as o:
1780 lines = o.readlines()
1786 with open(path, "w") as o:
1787 while i < len(lines):
1790 while line.endswith('\\\n'):
1791 line = line.rstrip('\\\n') + lines[i]
1794 if comment.match(line):
1798 opened += line.count('{')
1799 opened -= line.count('}')
1802 if signing_configs.match(line):
1807 if any(s.match(line) for s in line_matches):
1815 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1818 'project.properties',
1820 'default.properties',
1821 'ant.properties', ]:
1822 if propfile in files:
1823 path = os.path.join(root, propfile)
1825 with open(path, "r") as o:
1826 lines = o.readlines()
1830 with open(path, "w") as o:
1832 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1839 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1842 def reset_env_path():
1843 global env, orig_path
1844 env['PATH'] = orig_path
1847 def add_to_env_path(path):
1849 paths = env['PATH'].split(os.pathsep)
1853 env['PATH'] = os.pathsep.join(paths)
1856 def replace_config_vars(cmd, build):
1858 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1859 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1860 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1861 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1862 if build is not None:
1863 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1864 cmd = cmd.replace('$$VERSION$$', build['version'])
1865 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1869 def place_srclib(root_dir, number, libpath):
1872 relpath = os.path.relpath(libpath, root_dir)
1873 proppath = os.path.join(root_dir, 'project.properties')
1876 if os.path.isfile(proppath):
1877 with open(proppath, "r") as o:
1878 lines = o.readlines()
1880 with open(proppath, "w") as o:
1883 if line.startswith('android.library.reference.%d=' % number):
1884 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1889 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1892 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1893 """Verify that two apks are the same
1895 One of the inputs is signed, the other is unsigned. The signature metadata
1896 is transferred from the signed to the unsigned apk, and then jarsigner is
1897 used to verify that the signature from the signed apk is also varlid for
1899 :param signed_apk: Path to a signed apk file
1900 :param unsigned_apk: Path to an unsigned apk file expected to match it
1901 :param tmp_dir: Path to directory for temporary files
1902 :returns: None if the verification is successful, otherwise a string
1903 describing what went wrong.
1905 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1906 with ZipFile(signed_apk) as signed_apk_as_zip:
1907 meta_inf_files = ['META-INF/MANIFEST.MF']
1908 for f in signed_apk_as_zip.namelist():
1909 if sigfile.match(f):
1910 meta_inf_files.append(f)
1911 if len(meta_inf_files) < 3:
1912 return "Signature files missing from {0}".format(signed_apk)
1913 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1914 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1915 for meta_inf_file in meta_inf_files:
1916 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1918 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1919 logging.info("...NOT verified - {0}".format(signed_apk))
1920 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1921 logging.info("...successfully verified")
1925 def compare_apks(apk1, apk2, tmp_dir):
1928 Returns None if the apk content is the same (apart from the signing key),
1929 otherwise a string describing what's different, or what went wrong when
1930 trying to do the comparison.
1933 badchars = re.compile('''[/ :;'"]''')
1934 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1935 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1936 for d in [apk1dir, apk2dir]:
1937 if os.path.exists(d):
1940 os.mkdir(os.path.join(d, 'jar-xf'))
1942 if subprocess.call(['jar', 'xf',
1943 os.path.abspath(apk1)],
1944 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1945 return("Failed to unpack " + apk1)
1946 if subprocess.call(['jar', 'xf',
1947 os.path.abspath(apk2)],
1948 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1949 return("Failed to unpack " + apk2)
1951 # try to find apktool in the path, if it hasn't been manually configed
1952 if 'apktool' not in config:
1953 tmp = find_command('apktool')
1955 config['apktool'] = tmp
1956 if 'apktool' in config:
1957 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1959 return("Failed to unpack " + apk1)
1960 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1962 return("Failed to unpack " + apk2)
1964 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1965 lines = p.output.splitlines()
1966 if len(lines) != 1 or 'META-INF' not in lines[0]:
1967 meld = find_command('meld')
1968 if meld is not None:
1969 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1970 return("Unexpected diff output - " + p.output)
1972 # since everything verifies, delete the comparison to keep cruft down
1973 shutil.rmtree(apk1dir)
1974 shutil.rmtree(apk2dir)
1976 # If we get here, it seems like they're the same!
1980 def find_command(command):
1981 '''find the full path of a command, or None if it can't be found in the PATH'''
1984 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1986 fpath, fname = os.path.split(command)
1991 for path in os.environ["PATH"].split(os.pathsep):
1992 path = path.strip('"')
1993 exe_file = os.path.join(path, command)
1994 if is_exe(exe_file):
2001 '''generate a random password for when generating keys'''
2002 h = hashlib.sha256()
2003 h.update(os.urandom(16)) # salt
2004 h.update(bytes(socket.getfqdn()))
2005 return h.digest().encode('base64').strip()
2008 def genkeystore(localconfig):
2009 '''Generate a new key with random passwords and add it to new keystore'''
2010 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2011 keystoredir = os.path.dirname(localconfig['keystore'])
2012 if keystoredir is None or keystoredir == '':
2013 keystoredir = os.path.join(os.getcwd(), keystoredir)
2014 if not os.path.exists(keystoredir):
2015 os.makedirs(keystoredir, mode=0o700)
2017 write_password_file("keystorepass", localconfig['keystorepass'])
2018 write_password_file("keypass", localconfig['keypass'])
2019 p = FDroidPopen(['keytool', '-genkey',
2020 '-keystore', localconfig['keystore'],
2021 '-alias', localconfig['repo_keyalias'],
2022 '-keyalg', 'RSA', '-keysize', '4096',
2023 '-sigalg', 'SHA256withRSA',
2024 '-validity', '10000',
2025 '-storepass:file', config['keystorepassfile'],
2026 '-keypass:file', config['keypassfile'],
2027 '-dname', localconfig['keydname']])
2028 # TODO keypass should be sent via stdin
2029 os.chmod(localconfig['keystore'], 0o0600)
2030 if p.returncode != 0:
2031 raise BuildException("Failed to generate key", p.output)
2032 # now show the lovely key that was just generated
2033 p = FDroidPopen(['keytool', '-list', '-v',
2034 '-keystore', localconfig['keystore'],
2035 '-alias', localconfig['repo_keyalias'],
2036 '-storepass:file', config['keystorepassfile']])
2037 logging.info(p.output.strip() + '\n\n')
2040 def write_to_config(thisconfig, key, value=None):
2041 '''write a key/value to the local config.py'''
2043 origkey = key + '_orig'
2044 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2045 with open('config.py', 'r') as f:
2047 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2048 repl = '\n' + key + ' = "' + value + '"'
2049 data = re.sub(pattern, repl, data)
2050 # if this key is not in the file, append it
2051 if not re.match('\s*' + key + '\s*=\s*"', data):
2053 # make sure the file ends with a carraige return
2054 if not re.match('\n$', data):
2056 with open('config.py', 'w') as f:
2060 def parse_xml(path):
2061 return XMLElementTree.parse(path).getroot()
2064 def string_is_integer(string):