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 from distutils.version import LooseVersion
36 from zipfile import ZipFile
47 'sdk_path': "$ANDROID_HOME",
50 'r10d': "$ANDROID_NDK"
52 'build_tools': "22.0.0",
56 'sync_from_local_copy_dir': False,
57 'make_current_version_link': True,
58 'current_version_name_source': 'Name',
59 'update_stats': False,
63 'stats_to_carbon': False,
65 'build_server_always': False,
66 'keystore': 'keystore.jks',
67 'smartcardoptions': [],
73 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
74 'repo_name': "My First FDroid Repo Demo",
75 'repo_icon': "fdroid-icon.png",
76 'repo_description': '''
77 This is a repository of apps to be used with FDroid. Applications in this
78 repository are either official binaries built by the original application
79 developers, or are binaries built from source by the admin of f-droid.org
80 using the tools on https://gitlab.com/u/fdroid.
86 def fill_config_defaults(thisconfig):
87 for k, v in default_config.items():
88 if k not in thisconfig:
91 # Expand paths (~users and $vars)
92 def expand_path(path):
96 path = os.path.expanduser(path)
97 path = os.path.expandvars(path)
102 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
107 thisconfig[k + '_orig'] = v
109 for k in ['ndk_paths']:
115 thisconfig[k][k2] = exp
116 thisconfig[k][k2 + '_orig'] = v
119 def read_config(opts, config_file='config.py'):
120 """Read the repository config
122 The config is read from config_file, which is in the current directory when
123 any of the repo management commands are used.
125 global config, options, env, orig_path
127 if config is not None:
129 if not os.path.isfile(config_file):
130 logging.critical("Missing config file - is this a repo directory?")
137 logging.debug("Reading %s" % config_file)
138 execfile(config_file, config)
140 # smartcardoptions must be a list since its command line args for Popen
141 if 'smartcardoptions' in config:
142 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
143 elif 'keystore' in config and config['keystore'] == 'NONE':
144 # keystore='NONE' means use smartcard, these are required defaults
145 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
146 'SunPKCS11-OpenSC', '-providerClass',
147 'sun.security.pkcs11.SunPKCS11',
148 '-providerArg', 'opensc-fdroid.cfg']
150 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
151 st = os.stat(config_file)
152 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
153 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
155 fill_config_defaults(config)
157 # There is no standard, so just set up the most common environment
160 orig_path = env['PATH']
161 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
162 env[n] = config['sdk_path']
164 for k in ["keystorepass", "keypass"]:
166 write_password_file(k)
168 for k in ["repo_description", "archive_description"]:
170 config[k] = clean_description(config[k])
172 if 'serverwebroot' in config:
173 if isinstance(config['serverwebroot'], basestring):
174 roots = [config['serverwebroot']]
175 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
176 roots = config['serverwebroot']
178 raise TypeError('only accepts strings, lists, and tuples')
180 for rootstr in roots:
181 # since this is used with rsync, where trailing slashes have
182 # meaning, ensure there is always a trailing slash
183 if rootstr[-1] != '/':
185 rootlist.append(rootstr.replace('//', '/'))
186 config['serverwebroot'] = rootlist
191 def get_ndk_path(version):
193 version = 'r10d' # latest
194 paths = config['ndk_paths']
195 if version not in paths:
197 return paths[version] or ''
200 def find_sdk_tools_cmd(cmd):
201 '''find a working path to a tool from the Android SDK'''
204 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
205 # try to find a working path to this command, in all the recent possible paths
206 if 'build_tools' in config:
207 build_tools = os.path.join(config['sdk_path'], 'build-tools')
208 # if 'build_tools' was manually set and exists, check only that one
209 configed_build_tools = os.path.join(build_tools, config['build_tools'])
210 if os.path.exists(configed_build_tools):
211 tooldirs.append(configed_build_tools)
213 # no configed version, so hunt known paths for it
214 for f in sorted(os.listdir(build_tools), reverse=True):
215 if os.path.isdir(os.path.join(build_tools, f)):
216 tooldirs.append(os.path.join(build_tools, f))
217 tooldirs.append(build_tools)
218 sdk_tools = os.path.join(config['sdk_path'], 'tools')
219 if os.path.exists(sdk_tools):
220 tooldirs.append(sdk_tools)
221 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
222 if os.path.exists(sdk_platform_tools):
223 tooldirs.append(sdk_platform_tools)
224 tooldirs.append('/usr/bin')
226 if os.path.isfile(os.path.join(d, cmd)):
227 return os.path.join(d, cmd)
228 # did not find the command, exit with error message
229 ensure_build_tools_exists(config)
232 def test_sdk_exists(thisconfig):
233 if 'sdk_path' not in thisconfig:
234 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
237 logging.error("'sdk_path' not set in config.py!")
239 if thisconfig['sdk_path'] == default_config['sdk_path']:
240 logging.error('No Android SDK found!')
241 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
242 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
244 if not os.path.exists(thisconfig['sdk_path']):
245 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
247 if not os.path.isdir(thisconfig['sdk_path']):
248 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
250 for d in ['build-tools', 'platform-tools', 'tools']:
251 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
252 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
253 thisconfig['sdk_path'], d))
258 def ensure_build_tools_exists(thisconfig):
259 if not test_sdk_exists(thisconfig):
261 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
262 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
263 if not os.path.isdir(versioned_build_tools):
264 logging.critical('Android Build Tools path "'
265 + versioned_build_tools + '" does not exist!')
269 def write_password_file(pwtype, password=None):
271 writes out passwords to a protected file instead of passing passwords as
272 command line argments
274 filename = '.fdroid.' + pwtype + '.txt'
275 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
277 os.write(fd, config[pwtype])
279 os.write(fd, password)
281 config[pwtype + 'file'] = filename
284 # Given the arguments in the form of multiple appid:[vc] strings, this returns
285 # a dictionary with the set of vercodes specified for each package.
286 def read_pkg_args(args, allow_vercodes=False):
293 if allow_vercodes and ':' in p:
294 package, vercode = p.split(':')
296 package, vercode = p, None
297 if package not in vercodes:
298 vercodes[package] = [vercode] if vercode else []
300 elif vercode and vercode not in vercodes[package]:
301 vercodes[package] += [vercode] if vercode else []
306 # On top of what read_pkg_args does, this returns the whole app metadata, but
307 # limiting the builds list to the builds matching the vercodes specified.
308 def read_app_args(args, allapps, allow_vercodes=False):
310 vercodes = read_pkg_args(args, allow_vercodes)
316 for appid, app in allapps.iteritems():
317 if appid in vercodes:
320 if len(apps) != len(vercodes):
323 logging.critical("No such package: %s" % p)
324 raise FDroidException("Found invalid app ids in arguments")
326 raise FDroidException("No packages specified")
329 for appid, app in apps.iteritems():
333 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
334 if len(app['builds']) != len(vercodes[appid]):
336 allvcs = [b['vercode'] for b in app['builds']]
337 for v in vercodes[appid]:
339 logging.critical("No such vercode %s for app %s" % (v, appid))
342 raise FDroidException("Found invalid vercodes for some apps")
347 def has_extension(filename, extension):
348 name, ext = os.path.splitext(filename)
349 ext = ext.lower()[1:]
350 return ext == extension
355 def clean_description(description):
356 'Remove unneeded newlines and spaces from a block of description text'
358 # this is split up by paragraph to make removing the newlines easier
359 for paragraph in re.split(r'\n\n', description):
360 paragraph = re.sub('\r', '', paragraph)
361 paragraph = re.sub('\n', ' ', paragraph)
362 paragraph = re.sub(' {2,}', ' ', paragraph)
363 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
364 returnstring += paragraph + '\n\n'
365 return returnstring.rstrip('\n')
368 def apknameinfo(filename):
370 filename = os.path.basename(filename)
371 if apk_regex is None:
372 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
373 m = apk_regex.match(filename)
375 result = (m.group(1), m.group(2))
376 except AttributeError:
377 raise FDroidException("Invalid apk name: %s" % filename)
381 def getapkname(app, build):
382 return "%s_%s.apk" % (app['id'], build['vercode'])
385 def getsrcname(app, build):
386 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
393 return app['Auto Name']
398 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
401 def getvcs(vcstype, remote, local):
403 return vcs_git(remote, local)
404 if vcstype == 'git-svn':
405 return vcs_gitsvn(remote, local)
407 return vcs_hg(remote, local)
409 return vcs_bzr(remote, local)
410 if vcstype == 'srclib':
411 if local != os.path.join('build', 'srclib', remote):
412 raise VCSException("Error: srclib paths are hard-coded!")
413 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
415 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
416 raise VCSException("Invalid vcs type " + vcstype)
419 def getsrclibvcs(name):
420 if name not in metadata.srclibs:
421 raise VCSException("Missing srclib " + name)
422 return metadata.srclibs[name]['Repo Type']
427 def __init__(self, remote, local):
429 # svn, git-svn and bzr may require auth
431 if self.repotype() in ('git-svn', 'bzr'):
433 if self.repotype == 'git-svn':
434 raise VCSException("Authentication is not supported for git-svn")
435 self.username, remote = remote.split('@')
436 if ':' not in self.username:
437 raise VCSException("Password required with username")
438 self.username, self.password = self.username.split(':')
442 self.clone_failed = False
443 self.refreshed = False
449 # Take the local repository to a clean version of the given revision, which
450 # is specificed in the VCS's native format. Beforehand, the repository can
451 # be dirty, or even non-existent. If the repository does already exist
452 # locally, it will be updated from the origin, but only once in the
453 # lifetime of the vcs object.
454 # None is acceptable for 'rev' if you know you are cloning a clean copy of
455 # the repo - otherwise it must specify a valid revision.
456 def gotorevision(self, rev):
458 if self.clone_failed:
459 raise VCSException("Downloading the repository already failed once, not trying again.")
461 # The .fdroidvcs-id file for a repo tells us what VCS type
462 # and remote that directory was created from, allowing us to drop it
463 # automatically if either of those things changes.
464 fdpath = os.path.join(self.local, '..',
465 '.fdroidvcs-' + os.path.basename(self.local))
466 cdata = self.repotype() + ' ' + self.remote
469 if os.path.exists(self.local):
470 if os.path.exists(fdpath):
471 with open(fdpath, 'r') as f:
472 fsdata = f.read().strip()
477 logging.info("Repository details for %s changed - deleting" % (
481 logging.info("Repository details for %s missing - deleting" % (
484 shutil.rmtree(self.local)
489 self.gotorevisionx(rev)
490 except FDroidException, e:
493 # If necessary, write the .fdroidvcs file.
494 if writeback and not self.clone_failed:
495 with open(fdpath, 'w') as f:
501 # Derived classes need to implement this. It's called once basic checking
502 # has been performend.
503 def gotorevisionx(self, rev):
504 raise VCSException("This VCS type doesn't define gotorevisionx")
506 # Initialise and update submodules
507 def initsubmodules(self):
508 raise VCSException('Submodules not supported for this vcs type')
510 # Get a list of all known tags
512 if not self._gettags:
513 raise VCSException('gettags not supported for this vcs type')
515 for tag in self._gettags():
516 if re.match('[-A-Za-z0-9_. ]+$', tag):
520 def latesttags(self, tags, number):
521 """Get the most recent tags in a given list.
523 :param tags: a list of tags
524 :param number: the number to return
525 :returns: A list containing the most recent tags in the provided
526 list, up to the maximum number given.
528 raise VCSException('latesttags not supported for this vcs type')
530 # Get current commit reference (hash, revision, etc)
532 raise VCSException('getref not supported for this vcs type')
534 # Returns the srclib (name, path) used in setting up the current
545 # If the local directory exists, but is somehow not a git repository, git
546 # will traverse up the directory tree until it finds one that is (i.e.
547 # fdroidserver) and then we'll proceed to destroy it! This is called as
550 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
551 result = p.output.rstrip()
552 if not result.endswith(self.local):
553 raise VCSException('Repository mismatch')
555 def gotorevisionx(self, rev):
556 if not os.path.exists(self.local):
558 p = FDroidPopen(['git', 'clone', self.remote, self.local])
559 if p.returncode != 0:
560 self.clone_failed = True
561 raise VCSException("Git clone failed", p.output)
565 # Discard any working tree changes
566 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
567 if p.returncode != 0:
568 raise VCSException("Git reset failed", p.output)
569 # Remove untracked files now, in case they're tracked in the target
570 # revision (it happens!)
571 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
572 if p.returncode != 0:
573 raise VCSException("Git clean failed", p.output)
574 if not self.refreshed:
575 # Get latest commits and tags from remote
576 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
577 if p.returncode != 0:
578 raise VCSException("Git fetch failed", p.output)
579 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
580 if p.returncode != 0:
581 raise VCSException("Git fetch failed", p.output)
582 # Recreate origin/HEAD as git clone would do it, in case it disappeared
583 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
584 if p.returncode != 0:
585 lines = p.output.splitlines()
586 if 'Multiple remote HEAD branches' not in lines[0]:
587 raise VCSException("Git remote set-head failed", p.output)
588 branch = lines[1].split(' ')[-1]
589 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
590 if p2.returncode != 0:
591 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
592 self.refreshed = True
593 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
594 # a github repo. Most of the time this is the same as origin/master.
595 rev = rev or 'origin/HEAD'
596 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
597 if p.returncode != 0:
598 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
599 # Get rid of any uncontrolled files left behind
600 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
601 if p.returncode != 0:
602 raise VCSException("Git clean failed", p.output)
604 def initsubmodules(self):
606 submfile = os.path.join(self.local, '.gitmodules')
607 if not os.path.isfile(submfile):
608 raise VCSException("No git submodules available")
610 # fix submodules not accessible without an account and public key auth
611 with open(submfile, 'r') as f:
612 lines = f.readlines()
613 with open(submfile, 'w') as f:
615 if 'git@github.com' in line:
616 line = line.replace('git@github.com:', 'https://github.com/')
620 ['git', 'reset', '--hard'],
621 ['git', 'clean', '-dffx'],
623 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
624 if p.returncode != 0:
625 raise VCSException("Git submodule reset failed", p.output)
626 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
627 if p.returncode != 0:
628 raise VCSException("Git submodule sync failed", p.output)
629 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
630 if p.returncode != 0:
631 raise VCSException("Git submodule update failed", p.output)
635 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
636 return p.output.splitlines()
638 def latesttags(self, tags, number):
643 ['git', 'show', '--format=format:%ct', '-s', tag],
644 cwd=self.local, output=False)
645 # Timestamp is on the last line. For a normal tag, it's the only
646 # line, but for annotated tags, the rest of the info precedes it.
647 ts = int(p.output.splitlines()[-1])
650 for _, t in sorted(tl)[-number:]:
655 class vcs_gitsvn(vcs):
660 # If the local directory exists, but is somehow not a git repository, git
661 # will traverse up the directory tree until it finds one that is (i.e.
662 # fdroidserver) and then we'll proceed to destory it! This is called as
665 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
666 result = p.output.rstrip()
667 if not result.endswith(self.local):
668 raise VCSException('Repository mismatch')
670 def gotorevisionx(self, rev):
671 if not os.path.exists(self.local):
673 gitsvn_args = ['git', 'svn', 'clone']
674 if ';' in self.remote:
675 remote_split = self.remote.split(';')
676 for i in remote_split[1:]:
677 if i.startswith('trunk='):
678 gitsvn_args.extend(['-T', i[6:]])
679 elif i.startswith('tags='):
680 gitsvn_args.extend(['-t', i[5:]])
681 elif i.startswith('branches='):
682 gitsvn_args.extend(['-b', i[9:]])
683 gitsvn_args.extend([remote_split[0], self.local])
684 p = FDroidPopen(gitsvn_args, output=False)
685 if p.returncode != 0:
686 self.clone_failed = True
687 raise VCSException("Git svn clone failed", p.output)
689 gitsvn_args.extend([self.remote, self.local])
690 p = FDroidPopen(gitsvn_args, output=False)
691 if p.returncode != 0:
692 self.clone_failed = True
693 raise VCSException("Git svn clone failed", p.output)
697 # Discard any working tree changes
698 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
699 if p.returncode != 0:
700 raise VCSException("Git reset failed", p.output)
701 # Remove untracked files now, in case they're tracked in the target
702 # revision (it happens!)
703 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
704 if p.returncode != 0:
705 raise VCSException("Git clean failed", p.output)
706 if not self.refreshed:
707 # Get new commits, branches and tags from repo
708 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
709 if p.returncode != 0:
710 raise VCSException("Git svn fetch failed")
711 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
712 if p.returncode != 0:
713 raise VCSException("Git svn rebase failed", p.output)
714 self.refreshed = True
716 rev = rev or 'master'
718 nospaces_rev = rev.replace(' ', '%20')
719 # Try finding a svn tag
720 for treeish in ['origin/', '']:
721 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
722 if p.returncode == 0:
724 if p.returncode != 0:
725 # No tag found, normal svn rev translation
726 # Translate svn rev into git format
727 rev_split = rev.split('/')
730 for treeish in ['origin/', '']:
731 if len(rev_split) > 1:
732 treeish += rev_split[0]
733 svn_rev = rev_split[1]
736 # if no branch is specified, then assume trunk (i.e. 'master' branch):
740 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
742 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
743 git_rev = p.output.rstrip()
745 if p.returncode == 0 and git_rev:
748 if p.returncode != 0 or not git_rev:
749 # Try a plain git checkout as a last resort
750 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
751 if p.returncode != 0:
752 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
754 # Check out the git rev equivalent to the svn rev
755 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
756 if p.returncode != 0:
757 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
759 # Get rid of any uncontrolled files left behind
760 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
761 if p.returncode != 0:
762 raise VCSException("Git clean failed", p.output)
766 for treeish in ['origin/', '']:
767 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
773 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
774 if p.returncode != 0:
776 return p.output.strip()
784 def gotorevisionx(self, rev):
785 if not os.path.exists(self.local):
786 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
787 if p.returncode != 0:
788 self.clone_failed = True
789 raise VCSException("Hg clone failed", p.output)
791 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
792 if p.returncode != 0:
793 raise VCSException("Hg status failed", p.output)
794 for line in p.output.splitlines():
795 if not line.startswith('? '):
796 raise VCSException("Unexpected output from hg status -uS: " + line)
797 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
798 if not self.refreshed:
799 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
800 if p.returncode != 0:
801 raise VCSException("Hg pull failed", p.output)
802 self.refreshed = True
804 rev = rev or 'default'
807 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
808 if p.returncode != 0:
809 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
810 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
811 # Also delete untracked files, we have to enable purge extension for that:
812 if "'purge' is provided by the following extension" in p.output:
813 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
814 myfile.write("\n[extensions]\nhgext.purge=\n")
815 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
816 if p.returncode != 0:
817 raise VCSException("HG purge failed", p.output)
818 elif p.returncode != 0:
819 raise VCSException("HG purge failed", p.output)
822 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
823 return p.output.splitlines()[1:]
831 def gotorevisionx(self, rev):
832 if not os.path.exists(self.local):
833 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
834 if p.returncode != 0:
835 self.clone_failed = True
836 raise VCSException("Bzr branch failed", p.output)
838 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
839 if p.returncode != 0:
840 raise VCSException("Bzr revert failed", p.output)
841 if not self.refreshed:
842 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
843 if p.returncode != 0:
844 raise VCSException("Bzr update failed", p.output)
845 self.refreshed = True
847 revargs = list(['-r', rev] if rev else [])
848 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
849 if p.returncode != 0:
850 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
853 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
854 return [tag.split(' ')[0].strip() for tag in
855 p.output.splitlines()]
858 def retrieve_string(app_dir, string, xmlfiles=None):
861 os.path.join(app_dir, 'res'),
862 os.path.join(app_dir, 'src', 'main'),
867 for res_dir in res_dirs:
868 for r, d, f in os.walk(res_dir):
869 if os.path.basename(r) == 'values':
870 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
873 if string.startswith('@string/'):
874 string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
875 elif string.startswith('&') and string.endswith(';'):
876 string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
878 if string_search is not None:
879 for xmlfile in xmlfiles:
880 if not os.path.isfile(xmlfile):
882 for line in file(xmlfile):
883 matches = string_search(line)
885 return retrieve_string(app_dir, matches.group(1), xmlfiles)
888 return string.replace("\\'", "'")
891 # Return list of existing files that will be used to find the highest vercode
892 def manifest_paths(app_dir, flavours):
894 possible_manifests = \
895 [os.path.join(app_dir, 'AndroidManifest.xml'),
896 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
897 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
898 os.path.join(app_dir, 'build.gradle')]
900 for flavour in flavours:
903 possible_manifests.append(
904 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
906 return [path for path in possible_manifests if os.path.isfile(path)]
909 # Retrieve the package name. Returns the name, or None if not found.
910 def fetch_real_name(app_dir, flavours):
911 app_search = re.compile(r'.*<application.*').search
912 name_search = re.compile(r'.*android:label="([^"]+)".*').search
914 for f in manifest_paths(app_dir, flavours):
915 if not has_extension(f, 'xml') or not os.path.isfile(f):
917 logging.debug("fetch_real_name: Checking manifest at " + f)
923 matches = name_search(line)
925 stringname = matches.group(1)
926 logging.debug("fetch_real_name: using string " + stringname)
927 result = retrieve_string(app_dir, stringname)
929 result = result.strip()
934 # Retrieve the version name
935 def version_name(original, app_dir, flavours):
936 for f in manifest_paths(app_dir, flavours):
937 if not has_extension(f, 'xml'):
939 string = retrieve_string(app_dir, original)
945 def get_library_references(root_dir):
947 proppath = os.path.join(root_dir, 'project.properties')
948 if not os.path.isfile(proppath):
950 for line in file(proppath):
951 if not line.startswith('android.library.reference.'):
953 path = line.split('=')[1].strip()
954 relpath = os.path.join(root_dir, path)
955 if not os.path.isdir(relpath):
957 logging.debug("Found subproject at %s" % path)
958 libraries.append(path)
962 def ant_subprojects(root_dir):
963 subprojects = get_library_references(root_dir)
964 for subpath in subprojects:
965 subrelpath = os.path.join(root_dir, subpath)
966 for p in get_library_references(subrelpath):
967 relp = os.path.normpath(os.path.join(subpath, p))
968 if relp not in subprojects:
969 subprojects.insert(0, relp)
973 def remove_debuggable_flags(root_dir):
974 # Remove forced debuggable flags
975 logging.debug("Removing debuggable flags from %s" % root_dir)
976 for root, dirs, files in os.walk(root_dir):
977 if 'AndroidManifest.xml' in files:
978 path = os.path.join(root, 'AndroidManifest.xml')
979 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
980 if p.returncode != 0:
981 raise BuildException("Failed to remove debuggable flags of %s" % path)
984 # Extract some information from the AndroidManifest.xml at the given path.
985 # Returns (version, vercode, package), any or all of which might be None.
986 # All values returned are strings.
987 def parse_androidmanifests(paths, ignoreversions=None):
990 return (None, None, None)
992 vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
993 vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
994 psearch = re.compile(r'.*package="([^"]+)".*').search
996 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
997 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
998 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
1000 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1008 if not os.path.isfile(path):
1011 logging.debug("Parsing manifest at {0}".format(path))
1012 gradle = has_extension(path, 'gradle')
1015 # Remember package name, may be defined separately from version+vercode
1016 package = max_package
1018 for line in file(path):
1021 matches = psearch_g(line)
1023 matches = psearch(line)
1025 package = matches.group(1)
1028 matches = vnsearch_g(line)
1030 matches = vnsearch(line)
1032 version = matches.group(2 if gradle else 1)
1035 matches = vcsearch_g(line)
1037 matches = vcsearch(line)
1039 vercode = matches.group(1)
1041 logging.debug("..got package={0}, version={1}, vercode={2}"
1042 .format(package, version, vercode))
1044 # Always grab the package name and version name in case they are not
1045 # together with the highest version code
1046 if max_package is None and package is not None:
1047 max_package = package
1048 if max_version is None and version is not None:
1049 max_version = version
1051 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1052 if not ignoresearch or not ignoresearch(version):
1053 if version is not None:
1054 max_version = version
1055 if vercode is not None:
1056 max_vercode = vercode
1057 if package is not None:
1058 max_package = package
1060 max_version = "Ignore"
1062 if max_version is None:
1063 max_version = "Unknown"
1065 if max_package and not is_valid_package_name(max_package):
1066 raise FDroidException("Invalid package name {0}".format(max_package))
1068 return (max_version, max_vercode, max_package)
1071 def is_valid_package_name(name):
1072 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1075 class FDroidException(Exception):
1077 def __init__(self, value, detail=None):
1079 self.detail = detail
1081 def get_wikitext(self):
1082 ret = repr(self.value) + "\n"
1086 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1094 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1098 class VCSException(FDroidException):
1102 class BuildException(FDroidException):
1106 # Get the specified source library.
1107 # Returns the path to it. Normally this is the path to be used when referencing
1108 # it, which may be a subdirectory of the actual project. If you want the base
1109 # directory of the project, pass 'basepath=True'.
1110 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1111 basepath=False, raw=False, prepare=True, preponly=False):
1119 name, ref = spec.split('@')
1121 number, name = name.split(':', 1)
1123 name, subdir = name.split('/', 1)
1125 if name not in metadata.srclibs:
1126 raise VCSException('srclib ' + name + ' not found.')
1128 srclib = metadata.srclibs[name]
1130 sdir = os.path.join(srclib_dir, name)
1133 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1134 vcs.srclib = (name, number, sdir)
1136 vcs.gotorevision(ref)
1143 libdir = os.path.join(sdir, subdir)
1144 elif srclib["Subdir"]:
1145 for subdir in srclib["Subdir"]:
1146 libdir_candidate = os.path.join(sdir, subdir)
1147 if os.path.exists(libdir_candidate):
1148 libdir = libdir_candidate
1154 if srclib["Srclibs"]:
1156 for lib in srclib["Srclibs"].replace(';', ',').split(','):
1158 for t in srclibpaths:
1163 raise VCSException('Missing recursive srclib %s for %s' % (
1165 place_srclib(libdir, n, s_tuple[2])
1168 remove_signing_keys(sdir)
1169 remove_debuggable_flags(sdir)
1173 if srclib["Prepare"]:
1174 cmd = replace_config_vars(srclib["Prepare"])
1176 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1177 if p.returncode != 0:
1178 raise BuildException("Error running prepare command for srclib %s"
1184 return (name, number, libdir)
1187 # Prepare the source code for a particular build
1188 # 'vcs' - the appropriate vcs object for the application
1189 # 'app' - the application details from the metadata
1190 # 'build' - the build details from the metadata
1191 # 'build_dir' - the path to the build directory, usually
1193 # 'srclib_dir' - the path to the source libraries directory, usually
1195 # 'extlib_dir' - the path to the external libraries directory, usually
1197 # Returns the (root, srclibpaths) where:
1198 # 'root' is the root directory, which may be the same as 'build_dir' or may
1199 # be a subdirectory of it.
1200 # 'srclibpaths' is information on the srclibs being used
1201 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1203 # Optionally, the actual app source can be in a subdirectory
1205 root_dir = os.path.join(build_dir, build['subdir'])
1207 root_dir = build_dir
1209 # Get a working copy of the right revision
1210 logging.info("Getting source for revision " + build['commit'])
1211 vcs.gotorevision(build['commit'])
1213 # Initialise submodules if requred
1214 if build['submodules']:
1215 logging.info("Initialising submodules")
1216 vcs.initsubmodules()
1218 # Check that a subdir (if we're using one) exists. This has to happen
1219 # after the checkout, since it might not exist elsewhere
1220 if not os.path.exists(root_dir):
1221 raise BuildException('Missing subdir ' + root_dir)
1223 # Run an init command if one is required
1225 cmd = replace_config_vars(build['init'])
1226 logging.info("Running 'init' commands in %s" % root_dir)
1228 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1229 if p.returncode != 0:
1230 raise BuildException("Error running init command for %s:%s" %
1231 (app['id'], build['version']), p.output)
1233 # Apply patches if any
1235 logging.info("Applying patches")
1236 for patch in build['patch']:
1237 patch = patch.strip()
1238 logging.info("Applying " + patch)
1239 patch_path = os.path.join('metadata', app['id'], patch)
1240 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1241 if p.returncode != 0:
1242 raise BuildException("Failed to apply patch %s" % patch_path)
1244 # Get required source libraries
1246 if build['srclibs']:
1247 logging.info("Collecting source libraries")
1248 for lib in build['srclibs']:
1249 srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1252 for name, number, libpath in srclibpaths:
1253 place_srclib(root_dir, int(number) if number else None, libpath)
1255 basesrclib = vcs.getsrclib()
1256 # If one was used for the main source, add that too.
1258 srclibpaths.append(basesrclib)
1260 # Update the local.properties file
1261 localprops = [os.path.join(build_dir, 'local.properties')]
1263 localprops += [os.path.join(root_dir, 'local.properties')]
1264 for path in localprops:
1266 if os.path.isfile(path):
1267 logging.info("Updating local.properties file at %s" % path)
1273 logging.info("Creating local.properties file at %s" % path)
1274 # Fix old-fashioned 'sdk-location' by copying
1275 # from sdk.dir, if necessary
1276 if build['oldsdkloc']:
1277 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1278 re.S | re.M).group(1)
1279 props += "sdk-location=%s\n" % sdkloc
1281 props += "sdk.dir=%s\n" % config['sdk_path']
1282 props += "sdk-location=%s\n" % config['sdk_path']
1283 if build['ndk_path']:
1285 props += "ndk.dir=%s\n" % build['ndk_path']
1286 props += "ndk-location=%s\n" % build['ndk_path']
1287 # Add java.encoding if necessary
1288 if build['encoding']:
1289 props += "java.encoding=%s\n" % build['encoding']
1295 if build['type'] == 'gradle':
1296 flavours = build['gradle']
1298 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1299 gradlepluginver = None
1301 gradle_dirs = [root_dir]
1303 # Parent dir build.gradle
1304 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1305 if parent_dir.startswith(build_dir):
1306 gradle_dirs.append(parent_dir)
1308 for dir_path in gradle_dirs:
1311 if not os.path.isdir(dir_path):
1313 for filename in os.listdir(dir_path):
1314 if not filename.endswith('.gradle'):
1316 path = os.path.join(dir_path, filename)
1317 if not os.path.isfile(path):
1319 for line in file(path):
1320 match = version_regex.match(line)
1322 gradlepluginver = match.group(1)
1326 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1328 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1329 build['gradlepluginver'] = LooseVersion('0.11')
1332 n = build["target"].split('-')[1]
1333 FDroidPopen(['sed', '-i',
1334 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1335 'build.gradle'], cwd=root_dir, output=False)
1337 # Remove forced debuggable flags
1338 remove_debuggable_flags(root_dir)
1340 # Insert version code and number into the manifest if necessary
1341 if build['forceversion']:
1342 logging.info("Changing the version name")
1343 for path in manifest_paths(root_dir, flavours):
1344 if not os.path.isfile(path):
1346 if has_extension(path, 'xml'):
1347 p = FDroidPopen(['sed', '-i',
1348 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1349 path], output=False)
1350 if p.returncode != 0:
1351 raise BuildException("Failed to amend manifest")
1352 elif has_extension(path, 'gradle'):
1353 p = FDroidPopen(['sed', '-i',
1354 's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1355 path], output=False)
1356 if p.returncode != 0:
1357 raise BuildException("Failed to amend build.gradle")
1358 if build['forcevercode']:
1359 logging.info("Changing the version code")
1360 for path in manifest_paths(root_dir, flavours):
1361 if not os.path.isfile(path):
1363 if has_extension(path, 'xml'):
1364 p = FDroidPopen(['sed', '-i',
1365 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1366 path], output=False)
1367 if p.returncode != 0:
1368 raise BuildException("Failed to amend manifest")
1369 elif has_extension(path, 'gradle'):
1370 p = FDroidPopen(['sed', '-i',
1371 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1372 path], output=False)
1373 if p.returncode != 0:
1374 raise BuildException("Failed to amend build.gradle")
1376 # Delete unwanted files
1378 logging.info("Removing specified files")
1379 for part in getpaths(build_dir, build, 'rm'):
1380 dest = os.path.join(build_dir, part)
1381 logging.info("Removing {0}".format(part))
1382 if os.path.lexists(dest):
1383 if os.path.islink(dest):
1384 FDroidPopen(['unlink', dest], output=False)
1386 FDroidPopen(['rm', '-rf', dest], output=False)
1388 logging.info("...but it didn't exist")
1390 remove_signing_keys(build_dir)
1392 # Add required external libraries
1393 if build['extlibs']:
1394 logging.info("Collecting prebuilt libraries")
1395 libsdir = os.path.join(root_dir, 'libs')
1396 if not os.path.exists(libsdir):
1398 for lib in build['extlibs']:
1400 logging.info("...installing extlib {0}".format(lib))
1401 libf = os.path.basename(lib)
1402 libsrc = os.path.join(extlib_dir, lib)
1403 if not os.path.exists(libsrc):
1404 raise BuildException("Missing extlib file {0}".format(libsrc))
1405 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1407 # Run a pre-build command if one is required
1408 if build['prebuild']:
1409 logging.info("Running 'prebuild' commands in %s" % root_dir)
1411 cmd = replace_config_vars(build['prebuild'])
1413 # Substitute source library paths into prebuild commands
1414 for name, number, libpath in srclibpaths:
1415 libpath = os.path.relpath(libpath, root_dir)
1416 cmd = cmd.replace('$$' + name + '$$', libpath)
1418 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1419 if p.returncode != 0:
1420 raise BuildException("Error running prebuild command for %s:%s" %
1421 (app['id'], build['version']), p.output)
1423 # Generate (or update) the ant build file, build.xml...
1424 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1425 parms = ['android', 'update', 'lib-project']
1426 lparms = ['android', 'update', 'project']
1429 parms += ['-t', build['target']]
1430 lparms += ['-t', build['target']]
1431 if build['update'] == ['auto']:
1432 update_dirs = ant_subprojects(root_dir) + ['.']
1434 update_dirs = build['update']
1436 for d in update_dirs:
1437 subdir = os.path.join(root_dir, d)
1439 logging.debug("Updating main project")
1440 cmd = parms + ['-p', d]
1442 logging.debug("Updating subproject %s" % d)
1443 cmd = lparms + ['-p', d]
1444 p = SdkToolsPopen(cmd, cwd=root_dir)
1445 # Check to see whether an error was returned without a proper exit
1446 # code (this is the case for the 'no target set or target invalid'
1448 if p.returncode != 0 or p.output.startswith("Error: "):
1449 raise BuildException("Failed to update project at %s" % d, p.output)
1450 # Clean update dirs via ant
1452 logging.info("Cleaning subproject %s" % d)
1453 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1455 return (root_dir, srclibpaths)
1458 # Split and extend via globbing the paths from a field
1459 def getpaths(build_dir, build, field):
1461 for p in build[field]:
1463 full_path = os.path.join(build_dir, p)
1464 full_path = os.path.normpath(full_path)
1465 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1469 # Scan the source code in the given directory (and all subdirectories)
1470 # and return the number of fatal problems encountered
1471 def scan_source(build_dir, root_dir, thisbuild):
1475 # Common known non-free blobs (always lower case):
1477 re.compile(r'flurryagent', re.IGNORECASE),
1478 re.compile(r'paypal.*mpl', re.IGNORECASE),
1479 re.compile(r'google.*analytics', re.IGNORECASE),
1480 re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1481 re.compile(r'google.*ad.*view', re.IGNORECASE),
1482 re.compile(r'google.*admob', re.IGNORECASE),
1483 re.compile(r'google.*play.*services', re.IGNORECASE),
1484 re.compile(r'crittercism', re.IGNORECASE),
1485 re.compile(r'heyzap', re.IGNORECASE),
1486 re.compile(r'jpct.*ae', re.IGNORECASE),
1487 re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1488 re.compile(r'bugsense', re.IGNORECASE),
1489 re.compile(r'crashlytics', re.IGNORECASE),
1490 re.compile(r'ouya.*sdk', re.IGNORECASE),
1491 re.compile(r'libspen23', re.IGNORECASE),
1494 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1495 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1497 scanignore_worked = set()
1498 scandelete_worked = set()
1501 ms = magic.open(magic.MIME_TYPE)
1503 except AttributeError:
1507 for p in scanignore:
1508 if fd.startswith(p):
1509 scanignore_worked.add(p)
1514 for p in scandelete:
1515 if fd.startswith(p):
1516 scandelete_worked.add(p)
1520 def ignoreproblem(what, fd, fp):
1521 logging.info('Ignoring %s at %s' % (what, fd))
1524 def removeproblem(what, fd, fp):
1525 logging.info('Removing %s at %s' % (what, fd))
1529 def warnproblem(what, fd):
1530 logging.warn('Found %s at %s' % (what, fd))
1532 def handleproblem(what, fd, fp):
1534 return ignoreproblem(what, fd, fp)
1536 return removeproblem(what, fd, fp)
1537 logging.error('Found %s at %s' % (what, fd))
1540 # Iterate through all files in the source code
1541 for r, d, f in os.walk(build_dir, topdown=True):
1543 # It's topdown, so checking the basename is enough
1544 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1550 # Path (relative) to the file
1551 fp = os.path.join(r, curfile)
1552 fd = fp[len(build_dir) + 1:]
1555 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1556 except UnicodeError:
1557 warnproblem('malformed magic number', fd)
1559 if mime == 'application/x-sharedlib':
1560 count += handleproblem('shared library', fd, fp)
1562 elif mime == 'application/x-archive':
1563 count += handleproblem('static library', fd, fp)
1565 elif mime == 'application/x-executable':
1566 count += handleproblem('binary executable', fd, fp)
1568 elif mime == 'application/x-java-applet':
1569 count += handleproblem('Java compiled class', fd, fp)
1574 'application/java-archive',
1575 'application/octet-stream',
1578 if has_extension(fp, 'apk'):
1579 removeproblem('APK file', fd, fp)
1581 elif has_extension(fp, 'jar'):
1583 if any(suspect.match(curfile) for suspect in usual_suspects):
1584 count += handleproblem('usual supect', fd, fp)
1586 warnproblem('JAR file', fd)
1588 elif has_extension(fp, 'zip'):
1589 warnproblem('ZIP file', fd)
1592 warnproblem('unknown compressed or binary file', fd)
1594 elif has_extension(fp, 'java') and os.path.isfile(fp):
1595 if not os.path.isfile(fp):
1597 for line in file(fp):
1598 if 'DexClassLoader' in line:
1599 count += handleproblem('DexClassLoader', fd, fp)
1604 for p in scanignore:
1605 if p not in scanignore_worked:
1606 logging.error('Unused scanignore path: %s' % p)
1609 for p in scandelete:
1610 if p not in scandelete_worked:
1611 logging.error('Unused scandelete path: %s' % p)
1614 # Presence of a jni directory without buildjni=yes might
1615 # indicate a problem (if it's not a problem, explicitly use
1616 # buildjni=no to bypass this check)
1617 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1618 not thisbuild['buildjni']):
1619 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1628 self.path = os.path.join('stats', 'known_apks.txt')
1630 if os.path.isfile(self.path):
1631 for line in file(self.path):
1632 t = line.rstrip().split(' ')
1634 self.apks[t[0]] = (t[1], None)
1636 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1637 self.changed = False
1639 def writeifchanged(self):
1641 if not os.path.exists('stats'):
1643 f = open(self.path, 'w')
1645 for apk, app in self.apks.iteritems():
1647 line = apk + ' ' + appid
1649 line += ' ' + time.strftime('%Y-%m-%d', added)
1651 for line in sorted(lst):
1652 f.write(line + '\n')
1655 # Record an apk (if it's new, otherwise does nothing)
1656 # Returns the date it was added.
1657 def recordapk(self, apk, app):
1658 if apk not in self.apks:
1659 self.apks[apk] = (app, time.gmtime(time.time()))
1661 _, added = self.apks[apk]
1664 # Look up information - given the 'apkname', returns (app id, date added/None).
1665 # Or returns None for an unknown apk.
1666 def getapp(self, apkname):
1667 if apkname in self.apks:
1668 return self.apks[apkname]
1671 # Get the most recent 'num' apps added to the repo, as a list of package ids
1672 # with the most recent first.
1673 def getlatest(self, num):
1675 for apk, app in self.apks.iteritems():
1679 if apps[appid] > added:
1683 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1684 lst = [app for app, _ in sortedapps]
1689 def isApkDebuggable(apkfile, config):
1690 """Returns True if the given apk file is debuggable
1692 :param apkfile: full path to the apk to check"""
1694 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1696 if p.returncode != 0:
1697 logging.critical("Failed to get apk manifest information")
1699 for line in p.output.splitlines():
1700 if 'android:debuggable' in line and not line.endswith('0x0'):
1705 class AsynchronousFileReader(threading.Thread):
1708 Helper class to implement asynchronous reading of a file
1709 in a separate thread. Pushes read lines on a queue to
1710 be consumed in another thread.
1713 def __init__(self, fd, queue):
1714 assert isinstance(queue, Queue.Queue)
1715 assert callable(fd.readline)
1716 threading.Thread.__init__(self)
1721 '''The body of the tread: read lines and put them on the queue.'''
1722 for line in iter(self._fd.readline, ''):
1723 self._queue.put(line)
1726 '''Check whether there is no more content to expect.'''
1727 return not self.is_alive() and self._queue.empty()
1735 def SdkToolsPopen(commands, cwd=None, output=True):
1737 if cmd not in config:
1738 config[cmd] = find_sdk_tools_cmd(commands[0])
1739 return FDroidPopen([config[cmd]] + commands[1:],
1740 cwd=cwd, output=output)
1743 def FDroidPopen(commands, cwd=None, output=True):
1745 Run a command and capture the possibly huge output.
1747 :param commands: command and argument list like in subprocess.Popen
1748 :param cwd: optionally specifies a working directory
1749 :returns: A PopenResult.
1755 cwd = os.path.normpath(cwd)
1756 logging.debug("Directory: %s" % cwd)
1757 logging.debug("> %s" % ' '.join(commands))
1759 result = PopenResult()
1762 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1763 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1765 raise BuildException("OSError while trying to execute " +
1766 ' '.join(commands) + ': ' + str(e))
1768 stdout_queue = Queue.Queue()
1769 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1770 stdout_reader.start()
1772 # Check the queue for output (until there is no more to get)
1773 while not stdout_reader.eof():
1774 while not stdout_queue.empty():
1775 line = stdout_queue.get()
1776 if output and options.verbose:
1777 # Output directly to console
1778 sys.stderr.write(line)
1780 result.output += line
1784 result.returncode = p.wait()
1788 def remove_signing_keys(build_dir):
1789 comment = re.compile(r'[ ]*//')
1790 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1792 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1793 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1794 re.compile(r'.*variant\.outputFile = .*'),
1795 re.compile(r'.*output\.outputFile = .*'),
1796 re.compile(r'.*\.readLine\(.*'),
1798 for root, dirs, files in os.walk(build_dir):
1799 if 'build.gradle' in files:
1800 path = os.path.join(root, 'build.gradle')
1802 with open(path, "r") as o:
1803 lines = o.readlines()
1809 with open(path, "w") as o:
1810 while i < len(lines):
1813 while line.endswith('\\\n'):
1814 line = line.rstrip('\\\n') + lines[i]
1817 if comment.match(line):
1821 opened += line.count('{')
1822 opened -= line.count('}')
1825 if signing_configs.match(line):
1830 if any(s.match(line) for s in line_matches):
1838 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1841 'project.properties',
1843 'default.properties',
1844 'ant.properties', ]:
1845 if propfile in files:
1846 path = os.path.join(root, propfile)
1848 with open(path, "r") as o:
1849 lines = o.readlines()
1853 with open(path, "w") as o:
1855 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1862 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1865 def reset_env_path():
1866 global env, orig_path
1867 env['PATH'] = orig_path
1870 def add_to_env_path(path):
1872 paths = env['PATH'].split(os.pathsep)
1876 env['PATH'] = os.pathsep.join(paths)
1879 def replace_config_vars(cmd):
1881 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1882 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1883 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1884 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1888 def place_srclib(root_dir, number, libpath):
1891 relpath = os.path.relpath(libpath, root_dir)
1892 proppath = os.path.join(root_dir, 'project.properties')
1895 if os.path.isfile(proppath):
1896 with open(proppath, "r") as o:
1897 lines = o.readlines()
1899 with open(proppath, "w") as o:
1902 if line.startswith('android.library.reference.%d=' % number):
1903 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1908 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1911 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1912 """Verify that two apks are the same
1914 One of the inputs is signed, the other is unsigned. The signature metadata
1915 is transferred from the signed to the unsigned apk, and then jarsigner is
1916 used to verify that the signature from the signed apk is also varlid for
1918 :param signed_apk: Path to a signed apk file
1919 :param unsigned_apk: Path to an unsigned apk file expected to match it
1920 :param tmp_dir: Path to directory for temporary files
1921 :returns: None if the verification is successful, otherwise a string
1922 describing what went wrong.
1924 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1925 with ZipFile(signed_apk) as signed_apk_as_zip:
1926 meta_inf_files = ['META-INF/MANIFEST.MF']
1927 for f in signed_apk_as_zip.namelist():
1928 if sigfile.match(f):
1929 meta_inf_files.append(f)
1930 if len(meta_inf_files) < 3:
1931 return "Signature files missing from {0}".format(signed_apk)
1932 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1933 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1934 for meta_inf_file in meta_inf_files:
1935 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1937 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1938 logging.info("...NOT verified - {0}".format(signed_apk))
1939 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1940 logging.info("...successfully verified")
1944 def compare_apks(apk1, apk2, tmp_dir):
1947 Returns None if the apk content is the same (apart from the signing key),
1948 otherwise a string describing what's different, or what went wrong when
1949 trying to do the comparison.
1952 badchars = re.compile('''[/ :;'"]''')
1953 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1954 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1955 for d in [apk1dir, apk2dir]:
1956 if os.path.exists(d):
1959 os.mkdir(os.path.join(d, 'jar-xf'))
1961 if subprocess.call(['jar', 'xf',
1962 os.path.abspath(apk1)],
1963 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1964 return("Failed to unpack " + apk1)
1965 if subprocess.call(['jar', 'xf',
1966 os.path.abspath(apk2)],
1967 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1968 return("Failed to unpack " + apk2)
1970 # try to find apktool in the path, if it hasn't been manually configed
1971 if 'apktool' not in config:
1972 tmp = find_command('apktool')
1974 config['apktool'] = tmp
1975 if 'apktool' in config:
1976 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1978 return("Failed to unpack " + apk1)
1979 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1981 return("Failed to unpack " + apk2)
1983 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1984 lines = p.output.splitlines()
1985 if len(lines) != 1 or 'META-INF' not in lines[0]:
1986 meld = find_command('meld')
1987 if meld is not None:
1988 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1989 return("Unexpected diff output - " + p.output)
1991 # since everything verifies, delete the comparison to keep cruft down
1992 shutil.rmtree(apk1dir)
1993 shutil.rmtree(apk2dir)
1995 # If we get here, it seems like they're the same!
1999 def find_command(command):
2000 '''find the full path of a command, or None if it can't be found in the PATH'''
2003 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2005 fpath, fname = os.path.split(command)
2010 for path in os.environ["PATH"].split(os.pathsep):
2011 path = path.strip('"')
2012 exe_file = os.path.join(path, command)
2013 if is_exe(exe_file):
2020 '''generate a random password for when generating keys'''
2021 h = hashlib.sha256()
2022 h.update(os.urandom(16)) # salt
2023 h.update(bytes(socket.getfqdn()))
2024 return h.digest().encode('base64').strip()
2027 def genkey(keystore, repo_keyalias, password, keydname):
2028 '''generate a new keystore with a new key in it for signing repos'''
2029 logging.info('Generating a new key in "' + keystore + '"...')
2030 write_password_file("keystorepass", password)
2031 write_password_file("keypass", password)
2032 p = FDroidPopen(['keytool', '-genkey',
2033 '-keystore', keystore, '-alias', repo_keyalias,
2034 '-keyalg', 'RSA', '-keysize', '4096',
2035 '-sigalg', 'SHA256withRSA',
2036 '-validity', '10000',
2037 '-storepass:file', config['keystorepassfile'],
2038 '-keypass:file', config['keypassfile'],
2039 '-dname', keydname])
2040 # TODO keypass should be sent via stdin
2041 os.chmod(keystore, 0o0600)
2042 if p.returncode != 0:
2043 raise BuildException("Failed to generate key", p.output)
2044 # now show the lovely key that was just generated
2045 p = FDroidPopen(['keytool', '-list', '-v',
2046 '-keystore', keystore, '-alias', repo_keyalias,
2047 '-storepass:file', config['keystorepassfile']])
2048 logging.info(p.output.strip() + '\n\n')