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': "23.0.0",
60 'sync_from_local_copy_dir': False,
61 'per_app_repos': False,
62 'make_current_version_link': True,
63 'current_version_name_source': 'Name',
64 'update_stats': False,
68 'stats_to_carbon': False,
70 'build_server_always': False,
71 'keystore': 'keystore.jks',
72 'smartcardoptions': [],
78 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
79 'repo_name': "My First FDroid Repo Demo",
80 'repo_icon': "fdroid-icon.png",
81 'repo_description': '''
82 This is a repository of apps to be used with FDroid. Applications in this
83 repository are either official binaries built by the original application
84 developers, or are binaries built from source by the admin of f-droid.org
85 using the tools on https://gitlab.com/u/fdroid.
91 def fill_config_defaults(thisconfig):
92 for k, v in default_config.items():
93 if k not in thisconfig:
96 # Expand paths (~users and $vars)
97 def expand_path(path):
101 path = os.path.expanduser(path)
102 path = os.path.expandvars(path)
107 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
112 thisconfig[k + '_orig'] = v
114 for k in ['ndk_paths']:
120 thisconfig[k][k2] = exp
121 thisconfig[k][k2 + '_orig'] = v
124 def regsub_file(pattern, repl, path):
125 with open(path, 'r') as f:
127 text = re.sub(pattern, repl, text)
128 with open(path, 'w') as f:
132 def read_config(opts, config_file='config.py'):
133 """Read the repository config
135 The config is read from config_file, which is in the current directory when
136 any of the repo management commands are used.
138 global config, options, env, orig_path
140 if config is not None:
142 if not os.path.isfile(config_file):
143 logging.critical("Missing config file - is this a repo directory?")
150 logging.debug("Reading %s" % config_file)
151 execfile(config_file, config)
153 # smartcardoptions must be a list since its command line args for Popen
154 if 'smartcardoptions' in config:
155 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
156 elif 'keystore' in config and config['keystore'] == 'NONE':
157 # keystore='NONE' means use smartcard, these are required defaults
158 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
159 'SunPKCS11-OpenSC', '-providerClass',
160 'sun.security.pkcs11.SunPKCS11',
161 '-providerArg', 'opensc-fdroid.cfg']
163 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
164 st = os.stat(config_file)
165 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
166 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
168 fill_config_defaults(config)
170 # There is no standard, so just set up the most common environment
173 orig_path = env['PATH']
174 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
175 env[n] = config['sdk_path']
177 for k in ["keystorepass", "keypass"]:
179 write_password_file(k)
181 for k in ["repo_description", "archive_description"]:
183 config[k] = clean_description(config[k])
185 if 'serverwebroot' in config:
186 if isinstance(config['serverwebroot'], basestring):
187 roots = [config['serverwebroot']]
188 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
189 roots = config['serverwebroot']
191 raise TypeError('only accepts strings, lists, and tuples')
193 for rootstr in roots:
194 # since this is used with rsync, where trailing slashes have
195 # meaning, ensure there is always a trailing slash
196 if rootstr[-1] != '/':
198 rootlist.append(rootstr.replace('//', '/'))
199 config['serverwebroot'] = rootlist
204 def get_ndk_path(version):
206 version = 'r10e' # falls back to latest
207 paths = config['ndk_paths']
208 if version not in paths:
210 return paths[version] or ''
213 def find_sdk_tools_cmd(cmd):
214 '''find a working path to a tool from the Android SDK'''
217 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
218 # try to find a working path to this command, in all the recent possible paths
219 if 'build_tools' in config:
220 build_tools = os.path.join(config['sdk_path'], 'build-tools')
221 # if 'build_tools' was manually set and exists, check only that one
222 configed_build_tools = os.path.join(build_tools, config['build_tools'])
223 if os.path.exists(configed_build_tools):
224 tooldirs.append(configed_build_tools)
226 # no configed version, so hunt known paths for it
227 for f in sorted(os.listdir(build_tools), reverse=True):
228 if os.path.isdir(os.path.join(build_tools, f)):
229 tooldirs.append(os.path.join(build_tools, f))
230 tooldirs.append(build_tools)
231 sdk_tools = os.path.join(config['sdk_path'], 'tools')
232 if os.path.exists(sdk_tools):
233 tooldirs.append(sdk_tools)
234 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
235 if os.path.exists(sdk_platform_tools):
236 tooldirs.append(sdk_platform_tools)
237 tooldirs.append('/usr/bin')
239 if os.path.isfile(os.path.join(d, cmd)):
240 return os.path.join(d, cmd)
241 # did not find the command, exit with error message
242 ensure_build_tools_exists(config)
245 def test_sdk_exists(thisconfig):
246 if 'sdk_path' not in thisconfig:
247 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
250 logging.error("'sdk_path' not set in config.py!")
252 if thisconfig['sdk_path'] == default_config['sdk_path']:
253 logging.error('No Android SDK found!')
254 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
255 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
257 if not os.path.exists(thisconfig['sdk_path']):
258 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
260 if not os.path.isdir(thisconfig['sdk_path']):
261 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
263 for d in ['build-tools', 'platform-tools', 'tools']:
264 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
265 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
266 thisconfig['sdk_path'], d))
271 def ensure_build_tools_exists(thisconfig):
272 if not test_sdk_exists(thisconfig):
274 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
275 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
276 if not os.path.isdir(versioned_build_tools):
277 logging.critical('Android Build Tools path "'
278 + versioned_build_tools + '" does not exist!')
282 def write_password_file(pwtype, password=None):
284 writes out passwords to a protected file instead of passing passwords as
285 command line argments
287 filename = '.fdroid.' + pwtype + '.txt'
288 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
290 os.write(fd, config[pwtype])
292 os.write(fd, password)
294 config[pwtype + 'file'] = filename
297 # Given the arguments in the form of multiple appid:[vc] strings, this returns
298 # a dictionary with the set of vercodes specified for each package.
299 def read_pkg_args(args, allow_vercodes=False):
306 if allow_vercodes and ':' in p:
307 package, vercode = p.split(':')
309 package, vercode = p, None
310 if package not in vercodes:
311 vercodes[package] = [vercode] if vercode else []
313 elif vercode and vercode not in vercodes[package]:
314 vercodes[package] += [vercode] if vercode else []
319 # On top of what read_pkg_args does, this returns the whole app metadata, but
320 # limiting the builds list to the builds matching the vercodes specified.
321 def read_app_args(args, allapps, allow_vercodes=False):
323 vercodes = read_pkg_args(args, allow_vercodes)
329 for appid, app in allapps.iteritems():
330 if appid in vercodes:
333 if len(apps) != len(vercodes):
336 logging.critical("No such package: %s" % p)
337 raise FDroidException("Found invalid app ids in arguments")
339 raise FDroidException("No packages specified")
342 for appid, app in apps.iteritems():
346 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
347 if len(app['builds']) != len(vercodes[appid]):
349 allvcs = [b['vercode'] for b in app['builds']]
350 for v in vercodes[appid]:
352 logging.critical("No such vercode %s for app %s" % (v, appid))
355 raise FDroidException("Found invalid vercodes for some apps")
360 def has_extension(filename, extension):
361 name, ext = os.path.splitext(filename)
362 ext = ext.lower()[1:]
363 return ext == extension
368 def clean_description(description):
369 'Remove unneeded newlines and spaces from a block of description text'
371 # this is split up by paragraph to make removing the newlines easier
372 for paragraph in re.split(r'\n\n', description):
373 paragraph = re.sub('\r', '', paragraph)
374 paragraph = re.sub('\n', ' ', paragraph)
375 paragraph = re.sub(' {2,}', ' ', paragraph)
376 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
377 returnstring += paragraph + '\n\n'
378 return returnstring.rstrip('\n')
381 def apknameinfo(filename):
383 filename = os.path.basename(filename)
384 if apk_regex is None:
385 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
386 m = apk_regex.match(filename)
388 result = (m.group(1), m.group(2))
389 except AttributeError:
390 raise FDroidException("Invalid apk name: %s" % filename)
394 def getapkname(app, build):
395 return "%s_%s.apk" % (app['id'], build['vercode'])
398 def getsrcname(app, build):
399 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
406 return app['Auto Name']
411 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
414 def getvcs(vcstype, remote, local):
416 return vcs_git(remote, local)
417 if vcstype == 'git-svn':
418 return vcs_gitsvn(remote, local)
420 return vcs_hg(remote, local)
422 return vcs_bzr(remote, local)
423 if vcstype == 'srclib':
424 if local != os.path.join('build', 'srclib', remote):
425 raise VCSException("Error: srclib paths are hard-coded!")
426 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
428 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
429 raise VCSException("Invalid vcs type " + vcstype)
432 def getsrclibvcs(name):
433 if name not in metadata.srclibs:
434 raise VCSException("Missing srclib " + name)
435 return metadata.srclibs[name]['Repo Type']
440 def __init__(self, remote, local):
442 # svn, git-svn and bzr may require auth
444 if self.repotype() in ('git-svn', 'bzr'):
446 if self.repotype == 'git-svn':
447 raise VCSException("Authentication is not supported for git-svn")
448 self.username, remote = remote.split('@')
449 if ':' not in self.username:
450 raise VCSException("Password required with username")
451 self.username, self.password = self.username.split(':')
455 self.clone_failed = False
456 self.refreshed = False
462 # Take the local repository to a clean version of the given revision, which
463 # is specificed in the VCS's native format. Beforehand, the repository can
464 # be dirty, or even non-existent. If the repository does already exist
465 # locally, it will be updated from the origin, but only once in the
466 # lifetime of the vcs object.
467 # None is acceptable for 'rev' if you know you are cloning a clean copy of
468 # the repo - otherwise it must specify a valid revision.
469 def gotorevision(self, rev, refresh=True):
471 if self.clone_failed:
472 raise VCSException("Downloading the repository already failed once, not trying again.")
474 # The .fdroidvcs-id file for a repo tells us what VCS type
475 # and remote that directory was created from, allowing us to drop it
476 # automatically if either of those things changes.
477 fdpath = os.path.join(self.local, '..',
478 '.fdroidvcs-' + os.path.basename(self.local))
479 cdata = self.repotype() + ' ' + self.remote
482 if os.path.exists(self.local):
483 if os.path.exists(fdpath):
484 with open(fdpath, 'r') as f:
485 fsdata = f.read().strip()
490 logging.info("Repository details for %s changed - deleting" % (
494 logging.info("Repository details for %s missing - deleting" % (
497 shutil.rmtree(self.local)
501 self.refreshed = True
504 self.gotorevisionx(rev)
505 except FDroidException, e:
508 # If necessary, write the .fdroidvcs file.
509 if writeback and not self.clone_failed:
510 with open(fdpath, 'w') as f:
516 # Derived classes need to implement this. It's called once basic checking
517 # has been performend.
518 def gotorevisionx(self, rev):
519 raise VCSException("This VCS type doesn't define gotorevisionx")
521 # Initialise and update submodules
522 def initsubmodules(self):
523 raise VCSException('Submodules not supported for this vcs type')
525 # Get a list of all known tags
527 if not self._gettags:
528 raise VCSException('gettags not supported for this vcs type')
530 for tag in self._gettags():
531 if re.match('[-A-Za-z0-9_. ]+$', tag):
535 def latesttags(self, tags, number):
536 """Get the most recent tags in a given list.
538 :param tags: a list of tags
539 :param number: the number to return
540 :returns: A list containing the most recent tags in the provided
541 list, up to the maximum number given.
543 raise VCSException('latesttags not supported for this vcs type')
545 # Get current commit reference (hash, revision, etc)
547 raise VCSException('getref not supported for this vcs type')
549 # Returns the srclib (name, path) used in setting up the current
560 # If the local directory exists, but is somehow not a git repository, git
561 # will traverse up the directory tree until it finds one that is (i.e.
562 # fdroidserver) and then we'll proceed to destroy it! This is called as
565 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
566 result = p.output.rstrip()
567 if not result.endswith(self.local):
568 raise VCSException('Repository mismatch')
570 def gotorevisionx(self, rev):
571 if not os.path.exists(self.local):
573 p = FDroidPopen(['git', 'clone', self.remote, self.local])
574 if p.returncode != 0:
575 self.clone_failed = True
576 raise VCSException("Git clone failed", p.output)
580 # Discard any working tree changes
581 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
582 'git', 'reset', '--hard'], cwd=self.local, output=False)
583 if p.returncode != 0:
584 raise VCSException("Git reset failed", p.output)
585 # Remove untracked files now, in case they're tracked in the target
586 # revision (it happens!)
587 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
588 'git', 'clean', '-dffx'], cwd=self.local, output=False)
589 if p.returncode != 0:
590 raise VCSException("Git clean failed", p.output)
591 if not self.refreshed:
592 # Get latest commits and tags from remote
593 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
594 if p.returncode != 0:
595 raise VCSException("Git fetch failed", p.output)
596 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
597 if p.returncode != 0:
598 raise VCSException("Git fetch failed", p.output)
599 # Recreate origin/HEAD as git clone would do it, in case it disappeared
600 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
601 if p.returncode != 0:
602 lines = p.output.splitlines()
603 if 'Multiple remote HEAD branches' not in lines[0]:
604 raise VCSException("Git remote set-head failed", p.output)
605 branch = lines[1].split(' ')[-1]
606 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
607 if p2.returncode != 0:
608 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
609 self.refreshed = True
610 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
611 # a github repo. Most of the time this is the same as origin/master.
612 rev = rev or 'origin/HEAD'
613 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
614 if p.returncode != 0:
615 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
616 # Get rid of any uncontrolled files left behind
617 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
618 if p.returncode != 0:
619 raise VCSException("Git clean failed", p.output)
621 def initsubmodules(self):
623 submfile = os.path.join(self.local, '.gitmodules')
624 if not os.path.isfile(submfile):
625 raise VCSException("No git submodules available")
627 # fix submodules not accessible without an account and public key auth
628 with open(submfile, 'r') as f:
629 lines = f.readlines()
630 with open(submfile, 'w') as f:
632 if 'git@github.com' in line:
633 line = line.replace('git@github.com:', 'https://github.com/')
636 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
637 if p.returncode != 0:
638 raise VCSException("Git submodule sync failed", p.output)
639 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
640 if p.returncode != 0:
641 raise VCSException("Git submodule update failed", p.output)
645 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
646 return p.output.splitlines()
648 def latesttags(self, tags, number):
653 ['git', 'show', '--format=format:%ct', '-s', tag],
654 cwd=self.local, output=False)
655 # Timestamp is on the last line. For a normal tag, it's the only
656 # line, but for annotated tags, the rest of the info precedes it.
657 ts = int(p.output.splitlines()[-1])
660 for _, t in sorted(tl)[-number:]:
665 class vcs_gitsvn(vcs):
670 # If the local directory exists, but is somehow not a git repository, git
671 # will traverse up the directory tree until it finds one that is (i.e.
672 # fdroidserver) and then we'll proceed to destory it! This is called as
675 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
676 result = p.output.rstrip()
677 if not result.endswith(self.local):
678 raise VCSException('Repository mismatch')
680 def gotorevisionx(self, rev):
681 if not os.path.exists(self.local):
683 gitsvn_args = ['git', 'svn', 'clone']
684 if ';' in self.remote:
685 remote_split = self.remote.split(';')
686 for i in remote_split[1:]:
687 if i.startswith('trunk='):
688 gitsvn_args.extend(['-T', i[6:]])
689 elif i.startswith('tags='):
690 gitsvn_args.extend(['-t', i[5:]])
691 elif i.startswith('branches='):
692 gitsvn_args.extend(['-b', i[9:]])
693 gitsvn_args.extend([remote_split[0], 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)
699 gitsvn_args.extend([self.remote, self.local])
700 p = FDroidPopen(gitsvn_args, output=False)
701 if p.returncode != 0:
702 self.clone_failed = True
703 raise VCSException("Git svn clone failed", p.output)
707 # Discard any working tree changes
708 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
709 if p.returncode != 0:
710 raise VCSException("Git reset failed", p.output)
711 # Remove untracked files now, in case they're tracked in the target
712 # revision (it happens!)
713 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
714 if p.returncode != 0:
715 raise VCSException("Git clean failed", p.output)
716 if not self.refreshed:
717 # Get new commits, branches and tags from repo
718 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
719 if p.returncode != 0:
720 raise VCSException("Git svn fetch failed")
721 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
722 if p.returncode != 0:
723 raise VCSException("Git svn rebase failed", p.output)
724 self.refreshed = True
726 rev = rev or 'master'
728 nospaces_rev = rev.replace(' ', '%20')
729 # Try finding a svn tag
730 for treeish in ['origin/', '']:
731 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
732 if p.returncode == 0:
734 if p.returncode != 0:
735 # No tag found, normal svn rev translation
736 # Translate svn rev into git format
737 rev_split = rev.split('/')
740 for treeish in ['origin/', '']:
741 if len(rev_split) > 1:
742 treeish += rev_split[0]
743 svn_rev = rev_split[1]
746 # if no branch is specified, then assume trunk (i.e. 'master' branch):
750 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
752 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
753 git_rev = p.output.rstrip()
755 if p.returncode == 0 and git_rev:
758 if p.returncode != 0 or not git_rev:
759 # Try a plain git checkout as a last resort
760 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
761 if p.returncode != 0:
762 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
764 # Check out the git rev equivalent to the svn rev
765 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
766 if p.returncode != 0:
767 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
769 # Get rid of any uncontrolled files left behind
770 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
771 if p.returncode != 0:
772 raise VCSException("Git clean failed", p.output)
776 for treeish in ['origin/', '']:
777 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
783 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
784 if p.returncode != 0:
786 return p.output.strip()
794 def gotorevisionx(self, rev):
795 if not os.path.exists(self.local):
796 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
797 if p.returncode != 0:
798 self.clone_failed = True
799 raise VCSException("Hg clone failed", p.output)
801 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
802 if p.returncode != 0:
803 raise VCSException("Hg status failed", p.output)
804 for line in p.output.splitlines():
805 if not line.startswith('? '):
806 raise VCSException("Unexpected output from hg status -uS: " + line)
807 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
808 if not self.refreshed:
809 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
810 if p.returncode != 0:
811 raise VCSException("Hg pull failed", p.output)
812 self.refreshed = True
814 rev = rev or 'default'
817 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
818 if p.returncode != 0:
819 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
820 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
821 # Also delete untracked files, we have to enable purge extension for that:
822 if "'purge' is provided by the following extension" in p.output:
823 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
824 myfile.write("\n[extensions]\nhgext.purge=\n")
825 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
826 if p.returncode != 0:
827 raise VCSException("HG purge failed", p.output)
828 elif p.returncode != 0:
829 raise VCSException("HG purge failed", p.output)
832 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
833 return p.output.splitlines()[1:]
841 def gotorevisionx(self, rev):
842 if not os.path.exists(self.local):
843 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
844 if p.returncode != 0:
845 self.clone_failed = True
846 raise VCSException("Bzr branch failed", p.output)
848 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
849 if p.returncode != 0:
850 raise VCSException("Bzr revert failed", p.output)
851 if not self.refreshed:
852 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
853 if p.returncode != 0:
854 raise VCSException("Bzr update failed", p.output)
855 self.refreshed = True
857 revargs = list(['-r', rev] if rev else [])
858 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
859 if p.returncode != 0:
860 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
863 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
864 return [tag.split(' ')[0].strip() for tag in
865 p.output.splitlines()]
868 def unescape_string(string):
869 if string[0] == '"' and string[-1] == '"':
872 return string.replace("\\'", "'")
875 def retrieve_string(app_dir, string, xmlfiles=None):
880 os.path.join(app_dir, 'res'),
881 os.path.join(app_dir, 'src', 'main', 'res'),
883 for r, d, f in os.walk(res_dir):
884 if os.path.basename(r) == 'values':
885 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
887 if not string.startswith('@string/'):
888 return unescape_string(string)
890 name = string[len('@string/'):]
892 for path in xmlfiles:
893 if not os.path.isfile(path):
895 xml = parse_xml(path)
896 element = xml.find('string[@name="' + name + '"]')
897 if element is not None and element.text is not None:
898 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
903 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
904 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
907 # Return list of existing files that will be used to find the highest vercode
908 def manifest_paths(app_dir, flavours):
910 possible_manifests = \
911 [os.path.join(app_dir, 'AndroidManifest.xml'),
912 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
913 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
914 os.path.join(app_dir, 'build.gradle')]
916 for flavour in flavours:
919 possible_manifests.append(
920 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
922 return [path for path in possible_manifests if os.path.isfile(path)]
925 # Retrieve the package name. Returns the name, or None if not found.
926 def fetch_real_name(app_dir, flavours):
927 for path in manifest_paths(app_dir, flavours):
928 if not has_extension(path, 'xml') or not os.path.isfile(path):
930 logging.debug("fetch_real_name: Checking manifest at " + path)
931 xml = parse_xml(path)
932 app = xml.find('application')
933 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
935 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
936 result = retrieve_string_singleline(app_dir, label)
938 result = result.strip()
943 def get_library_references(root_dir):
945 proppath = os.path.join(root_dir, 'project.properties')
946 if not os.path.isfile(proppath):
948 for line in file(proppath):
949 if not line.startswith('android.library.reference.'):
951 path = line.split('=')[1].strip()
952 relpath = os.path.join(root_dir, path)
953 if not os.path.isdir(relpath):
955 logging.debug("Found subproject at %s" % path)
956 libraries.append(path)
960 def ant_subprojects(root_dir):
961 subprojects = get_library_references(root_dir)
962 for subpath in subprojects:
963 subrelpath = os.path.join(root_dir, subpath)
964 for p in get_library_references(subrelpath):
965 relp = os.path.normpath(os.path.join(subpath, p))
966 if relp not in subprojects:
967 subprojects.insert(0, relp)
971 def remove_debuggable_flags(root_dir):
972 # Remove forced debuggable flags
973 logging.debug("Removing debuggable flags from %s" % root_dir)
974 for root, dirs, files in os.walk(root_dir):
975 if 'AndroidManifest.xml' in files:
976 regsub_file(r'android:debuggable="[^"]*"',
978 os.path.join(root, 'AndroidManifest.xml'))
981 # Extract some information from the AndroidManifest.xml at the given path.
982 # Returns (version, vercode, package), any or all of which might be None.
983 # All values returned are strings.
984 def parse_androidmanifests(paths, ignoreversions=None):
987 return (None, None, None)
989 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
990 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
991 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
993 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1001 if not os.path.isfile(path):
1004 logging.debug("Parsing manifest at {0}".format(path))
1005 gradle = has_extension(path, 'gradle')
1008 # Remember package name, may be defined separately from version+vercode
1009 package = max_package
1012 for line in file(path):
1014 matches = psearch_g(line)
1016 package = matches.group(1)
1018 matches = vnsearch_g(line)
1020 version = matches.group(2)
1022 matches = vcsearch_g(line)
1024 vercode = matches.group(1)
1026 xml = parse_xml(path)
1027 if "package" in xml.attrib:
1028 package = xml.attrib["package"].encode('utf-8')
1029 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1030 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1031 base_dir = os.path.dirname(path)
1032 version = retrieve_string_singleline(base_dir, version)
1033 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1034 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1035 if string_is_integer(a):
1038 logging.debug("..got package={0}, version={1}, vercode={2}"
1039 .format(package, version, vercode))
1041 # Always grab the package name and version name in case they are not
1042 # together with the highest version code
1043 if max_package is None and package is not None:
1044 max_package = package
1045 if max_version is None and version is not None:
1046 max_version = version
1048 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1049 if not ignoresearch or not ignoresearch(version):
1050 if version is not None:
1051 max_version = version
1052 if vercode is not None:
1053 max_vercode = vercode
1054 if package is not None:
1055 max_package = package
1057 max_version = "Ignore"
1059 if max_version is None:
1060 max_version = "Unknown"
1062 if max_package and not is_valid_package_name(max_package):
1063 raise FDroidException("Invalid package name {0}".format(max_package))
1065 return (max_version, max_vercode, max_package)
1068 def is_valid_package_name(name):
1069 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1072 class FDroidException(Exception):
1074 def __init__(self, value, detail=None):
1076 self.detail = detail
1078 def get_wikitext(self):
1079 ret = repr(self.value) + "\n"
1083 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1091 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1095 class VCSException(FDroidException):
1099 class BuildException(FDroidException):
1103 # Get the specified source library.
1104 # Returns the path to it. Normally this is the path to be used when referencing
1105 # it, which may be a subdirectory of the actual project. If you want the base
1106 # directory of the project, pass 'basepath=True'.
1107 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1108 raw=False, prepare=True, preponly=False, refresh=True):
1116 name, ref = spec.split('@')
1118 number, name = name.split(':', 1)
1120 name, subdir = name.split('/', 1)
1122 if name not in metadata.srclibs:
1123 raise VCSException('srclib ' + name + ' not found.')
1125 srclib = metadata.srclibs[name]
1127 sdir = os.path.join(srclib_dir, name)
1130 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1131 vcs.srclib = (name, number, sdir)
1133 vcs.gotorevision(ref, refresh)
1140 libdir = os.path.join(sdir, subdir)
1141 elif srclib["Subdir"]:
1142 for subdir in srclib["Subdir"]:
1143 libdir_candidate = os.path.join(sdir, subdir)
1144 if os.path.exists(libdir_candidate):
1145 libdir = libdir_candidate
1151 remove_signing_keys(sdir)
1152 remove_debuggable_flags(sdir)
1156 if srclib["Prepare"]:
1157 cmd = replace_config_vars(srclib["Prepare"], None)
1159 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1160 if p.returncode != 0:
1161 raise BuildException("Error running prepare command for srclib %s"
1167 return (name, number, libdir)
1170 # Prepare the source code for a particular build
1171 # 'vcs' - the appropriate vcs object for the application
1172 # 'app' - the application details from the metadata
1173 # 'build' - the build details from the metadata
1174 # 'build_dir' - the path to the build directory, usually
1176 # 'srclib_dir' - the path to the source libraries directory, usually
1178 # 'extlib_dir' - the path to the external libraries directory, usually
1180 # Returns the (root, srclibpaths) where:
1181 # 'root' is the root directory, which may be the same as 'build_dir' or may
1182 # be a subdirectory of it.
1183 # 'srclibpaths' is information on the srclibs being used
1184 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1186 # Optionally, the actual app source can be in a subdirectory
1188 root_dir = os.path.join(build_dir, build['subdir'])
1190 root_dir = build_dir
1192 # Get a working copy of the right revision
1193 logging.info("Getting source for revision " + build['commit'])
1194 vcs.gotorevision(build['commit'], refresh)
1196 # Initialise submodules if required
1197 if build['submodules']:
1198 logging.info("Initialising submodules")
1199 vcs.initsubmodules()
1201 # Check that a subdir (if we're using one) exists. This has to happen
1202 # after the checkout, since it might not exist elsewhere
1203 if not os.path.exists(root_dir):
1204 raise BuildException('Missing subdir ' + root_dir)
1206 # Run an init command if one is required
1208 cmd = replace_config_vars(build['init'], build)
1209 logging.info("Running 'init' commands in %s" % root_dir)
1211 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1212 if p.returncode != 0:
1213 raise BuildException("Error running init command for %s:%s" %
1214 (app['id'], build['version']), p.output)
1216 # Apply patches if any
1218 logging.info("Applying patches")
1219 for patch in build['patch']:
1220 patch = patch.strip()
1221 logging.info("Applying " + patch)
1222 patch_path = os.path.join('metadata', app['id'], patch)
1223 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1224 if p.returncode != 0:
1225 raise BuildException("Failed to apply patch %s" % patch_path)
1227 # Get required source libraries
1229 if build['srclibs']:
1230 logging.info("Collecting source libraries")
1231 for lib in build['srclibs']:
1232 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1234 for name, number, libpath in srclibpaths:
1235 place_srclib(root_dir, int(number) if number else None, libpath)
1237 basesrclib = vcs.getsrclib()
1238 # If one was used for the main source, add that too.
1240 srclibpaths.append(basesrclib)
1242 # Update the local.properties file
1243 localprops = [os.path.join(build_dir, 'local.properties')]
1245 localprops += [os.path.join(root_dir, 'local.properties')]
1246 for path in localprops:
1248 if os.path.isfile(path):
1249 logging.info("Updating local.properties file at %s" % path)
1255 logging.info("Creating local.properties file at %s" % path)
1256 # Fix old-fashioned 'sdk-location' by copying
1257 # from sdk.dir, if necessary
1258 if build['oldsdkloc']:
1259 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1260 re.S | re.M).group(1)
1261 props += "sdk-location=%s\n" % sdkloc
1263 props += "sdk.dir=%s\n" % config['sdk_path']
1264 props += "sdk-location=%s\n" % config['sdk_path']
1265 if build['ndk_path']:
1267 props += "ndk.dir=%s\n" % build['ndk_path']
1268 props += "ndk-location=%s\n" % build['ndk_path']
1269 # Add java.encoding if necessary
1270 if build['encoding']:
1271 props += "java.encoding=%s\n" % build['encoding']
1277 if build['type'] == 'gradle':
1278 flavours = build['gradle']
1280 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1281 gradlepluginver = None
1283 gradle_dirs = [root_dir]
1285 # Parent dir build.gradle
1286 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1287 if parent_dir.startswith(build_dir):
1288 gradle_dirs.append(parent_dir)
1290 for dir_path in gradle_dirs:
1293 if not os.path.isdir(dir_path):
1295 for filename in os.listdir(dir_path):
1296 if not filename.endswith('.gradle'):
1298 path = os.path.join(dir_path, filename)
1299 if not os.path.isfile(path):
1301 for line in file(path):
1302 match = version_regex.match(line)
1304 gradlepluginver = match.group(1)
1308 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1310 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1311 build['gradlepluginver'] = LooseVersion('0.11')
1314 n = build["target"].split('-')[1]
1315 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1316 r'compileSdkVersion %s' % n,
1317 os.path.join(root_dir, 'build.gradle'))
1319 # Remove forced debuggable flags
1320 remove_debuggable_flags(root_dir)
1322 # Insert version code and number into the manifest if necessary
1323 if build['forceversion']:
1324 logging.info("Changing the version name")
1325 for path in manifest_paths(root_dir, flavours):
1326 if not os.path.isfile(path):
1328 if has_extension(path, 'xml'):
1329 regsub_file(r'android:versionName="[^"]*"',
1330 r'android:versionName="%s"' % build['version'],
1332 elif has_extension(path, 'gradle'):
1333 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1334 r"""\1versionName '%s'""" % build['version'],
1337 if build['forcevercode']:
1338 logging.info("Changing the version code")
1339 for path in manifest_paths(root_dir, flavours):
1340 if not os.path.isfile(path):
1342 if has_extension(path, 'xml'):
1343 regsub_file(r'android:versionCode="[^"]*"',
1344 r'android:versionCode="%s"' % build['vercode'],
1346 elif has_extension(path, 'gradle'):
1347 regsub_file(r'versionCode[ =]+[0-9]+',
1348 r'versionCode %s' % build['vercode'],
1351 # Delete unwanted files
1353 logging.info("Removing specified files")
1354 for part in getpaths(build_dir, build, 'rm'):
1355 dest = os.path.join(build_dir, part)
1356 logging.info("Removing {0}".format(part))
1357 if os.path.lexists(dest):
1358 if os.path.islink(dest):
1359 FDroidPopen(['unlink', dest], output=False)
1361 FDroidPopen(['rm', '-rf', dest], output=False)
1363 logging.info("...but it didn't exist")
1365 remove_signing_keys(build_dir)
1367 # Add required external libraries
1368 if build['extlibs']:
1369 logging.info("Collecting prebuilt libraries")
1370 libsdir = os.path.join(root_dir, 'libs')
1371 if not os.path.exists(libsdir):
1373 for lib in build['extlibs']:
1375 logging.info("...installing extlib {0}".format(lib))
1376 libf = os.path.basename(lib)
1377 libsrc = os.path.join(extlib_dir, lib)
1378 if not os.path.exists(libsrc):
1379 raise BuildException("Missing extlib file {0}".format(libsrc))
1380 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1382 # Run a pre-build command if one is required
1383 if build['prebuild']:
1384 logging.info("Running 'prebuild' commands in %s" % root_dir)
1386 cmd = replace_config_vars(build['prebuild'], build)
1388 # Substitute source library paths into prebuild commands
1389 for name, number, libpath in srclibpaths:
1390 libpath = os.path.relpath(libpath, root_dir)
1391 cmd = cmd.replace('$$' + name + '$$', libpath)
1393 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1394 if p.returncode != 0:
1395 raise BuildException("Error running prebuild command for %s:%s" %
1396 (app['id'], build['version']), p.output)
1398 # Generate (or update) the ant build file, build.xml...
1399 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1400 parms = ['android', 'update', 'lib-project']
1401 lparms = ['android', 'update', 'project']
1404 parms += ['-t', build['target']]
1405 lparms += ['-t', build['target']]
1406 if build['update'] == ['auto']:
1407 update_dirs = ant_subprojects(root_dir) + ['.']
1409 update_dirs = build['update']
1411 for d in update_dirs:
1412 subdir = os.path.join(root_dir, d)
1414 logging.debug("Updating main project")
1415 cmd = parms + ['-p', d]
1417 logging.debug("Updating subproject %s" % d)
1418 cmd = lparms + ['-p', d]
1419 p = SdkToolsPopen(cmd, cwd=root_dir)
1420 # Check to see whether an error was returned without a proper exit
1421 # code (this is the case for the 'no target set or target invalid'
1423 if p.returncode != 0 or p.output.startswith("Error: "):
1424 raise BuildException("Failed to update project at %s" % d, p.output)
1425 # Clean update dirs via ant
1427 logging.info("Cleaning subproject %s" % d)
1428 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1430 return (root_dir, srclibpaths)
1433 # Split and extend via globbing the paths from a field
1434 def getpaths(build_dir, build, field):
1436 for p in build[field]:
1438 full_path = os.path.join(build_dir, p)
1439 full_path = os.path.normpath(full_path)
1440 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1444 def init_mime_type():
1446 There are two incompatible versions of the 'magic' module, one
1447 that comes as part of libmagic, which is what Debian includes as
1448 python-magic, then another called python-magic that is a separate
1449 project that wraps libmagic. The second is 'magic' on pypi, so
1450 both need to be supported. Then on platforms where libmagic is
1451 not easily included, e.g. OSX and Windows, fallback to the
1452 built-in 'mimetypes' module so this will work without
1453 libmagic. Hence this function with the following hacks:
1460 def mime_from_file(path):
1462 return magic.from_file(path, mime=True)
1463 except UnicodeError:
1466 def mime_file(path):
1468 return ms.file(path)
1469 except UnicodeError:
1472 def mime_guess_type(path):
1473 return mimetypes.guess_type(path, strict=False)
1478 ms = magic.open(magic.MIME_TYPE)
1480 magic.from_file(init_path, mime=True)
1481 method = 'from_file'
1482 except AttributeError:
1488 method = 'guess_type'
1490 logging.info("Using magic method " + method)
1491 if method == 'from_file':
1492 return mime_from_file
1493 if method == 'file':
1495 if method == 'guess_type':
1496 return mime_guess_type
1498 logging.critical("unknown magic method!")
1501 # Scan the source code in the given directory (and all subdirectories)
1502 # and return the number of fatal problems encountered
1503 def scan_source(build_dir, root_dir, thisbuild):
1507 # Common known non-free blobs (always lower case):
1509 re.compile(r'.*flurryagent', re.IGNORECASE),
1510 re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1511 re.compile(r'.*google.*analytics', re.IGNORECASE),
1512 re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1513 re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1514 re.compile(r'.*google.*admob', re.IGNORECASE),
1515 re.compile(r'.*google.*play.*services', re.IGNORECASE),
1516 re.compile(r'.*crittercism', re.IGNORECASE),
1517 re.compile(r'.*heyzap', re.IGNORECASE),
1518 re.compile(r'.*jpct.*ae', re.IGNORECASE),
1519 re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1520 re.compile(r'.*bugsense', re.IGNORECASE),
1521 re.compile(r'.*crashlytics', re.IGNORECASE),
1522 re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1523 re.compile(r'.*libspen23', re.IGNORECASE),
1526 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1527 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1529 scanignore_worked = set()
1530 scandelete_worked = set()
1533 for p in scanignore:
1534 if fd.startswith(p):
1535 scanignore_worked.add(p)
1540 for p in scandelete:
1541 if fd.startswith(p):
1542 scandelete_worked.add(p)
1546 def ignoreproblem(what, fd, fp):
1547 logging.info('Ignoring %s at %s' % (what, fd))
1550 def removeproblem(what, fd, fp):
1551 logging.info('Removing %s at %s' % (what, fd))
1555 def warnproblem(what, fd):
1556 logging.warn('Found %s at %s' % (what, fd))
1558 def handleproblem(what, fd, fp):
1560 return ignoreproblem(what, fd, fp)
1562 return removeproblem(what, fd, fp)
1563 logging.error('Found %s at %s' % (what, fd))
1566 get_mime_type = init_mime_type()
1568 # Iterate through all files in the source code
1569 for r, d, f in os.walk(build_dir, topdown=True):
1571 # It's topdown, so checking the basename is enough
1572 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1578 # Path (relative) to the file
1579 fp = os.path.join(r, curfile)
1580 fd = fp[len(build_dir) + 1:]
1582 mime = get_mime_type(fp)
1584 if mime == 'application/x-sharedlib':
1585 count += handleproblem('shared library', fd, fp)
1587 elif mime == 'application/x-archive':
1588 count += handleproblem('static library', fd, fp)
1590 elif mime == 'application/x-executable' or mime == 'application/x-mach-binary':
1591 count += handleproblem('binary executable', fd, fp)
1593 elif mime == 'application/x-java-applet':
1594 count += handleproblem('Java compiled class', fd, fp)
1599 'application/java-archive',
1600 'application/octet-stream',
1603 if has_extension(fp, 'apk'):
1604 removeproblem('APK file', fd, fp)
1606 elif has_extension(fp, 'jar'):
1608 if any(suspect.match(curfile) for suspect in usual_suspects):
1609 count += handleproblem('usual supect', fd, fp)
1611 warnproblem('JAR file', fd)
1613 elif has_extension(fp, 'zip'):
1614 warnproblem('ZIP file', fd)
1617 warnproblem('unknown compressed or binary file', fd)
1619 elif has_extension(fp, 'java'):
1620 if not os.path.isfile(fp):
1622 for line in file(fp):
1623 if 'DexClassLoader' in line:
1624 count += handleproblem('DexClassLoader', fd, fp)
1627 elif has_extension(fp, 'gradle'):
1628 if not os.path.isfile(fp):
1630 for i, line in enumerate(file(fp)):
1632 if any(suspect.match(line) for suspect in usual_suspects):
1633 count += handleproblem('usual suspect at line %d' % i, fd, fp)
1636 for p in scanignore:
1637 if p not in scanignore_worked:
1638 logging.error('Unused scanignore path: %s' % p)
1641 for p in scandelete:
1642 if p not in scandelete_worked:
1643 logging.error('Unused scandelete path: %s' % p)
1646 # Presence of a jni directory without buildjni=yes might
1647 # indicate a problem (if it's not a problem, explicitly use
1648 # buildjni=no to bypass this check)
1649 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1650 not thisbuild['buildjni']):
1651 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1660 self.path = os.path.join('stats', 'known_apks.txt')
1662 if os.path.isfile(self.path):
1663 for line in file(self.path):
1664 t = line.rstrip().split(' ')
1666 self.apks[t[0]] = (t[1], None)
1668 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1669 self.changed = False
1671 def writeifchanged(self):
1673 if not os.path.exists('stats'):
1675 f = open(self.path, 'w')
1677 for apk, app in self.apks.iteritems():
1679 line = apk + ' ' + appid
1681 line += ' ' + time.strftime('%Y-%m-%d', added)
1683 for line in sorted(lst):
1684 f.write(line + '\n')
1687 # Record an apk (if it's new, otherwise does nothing)
1688 # Returns the date it was added.
1689 def recordapk(self, apk, app):
1690 if apk not in self.apks:
1691 self.apks[apk] = (app, time.gmtime(time.time()))
1693 _, added = self.apks[apk]
1696 # Look up information - given the 'apkname', returns (app id, date added/None).
1697 # Or returns None for an unknown apk.
1698 def getapp(self, apkname):
1699 if apkname in self.apks:
1700 return self.apks[apkname]
1703 # Get the most recent 'num' apps added to the repo, as a list of package ids
1704 # with the most recent first.
1705 def getlatest(self, num):
1707 for apk, app in self.apks.iteritems():
1711 if apps[appid] > added:
1715 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1716 lst = [app for app, _ in sortedapps]
1721 def isApkDebuggable(apkfile, config):
1722 """Returns True if the given apk file is debuggable
1724 :param apkfile: full path to the apk to check"""
1726 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1728 if p.returncode != 0:
1729 logging.critical("Failed to get apk manifest information")
1731 for line in p.output.splitlines():
1732 if 'android:debuggable' in line and not line.endswith('0x0'):
1737 class AsynchronousFileReader(threading.Thread):
1740 Helper class to implement asynchronous reading of a file
1741 in a separate thread. Pushes read lines on a queue to
1742 be consumed in another thread.
1745 def __init__(self, fd, queue):
1746 assert isinstance(queue, Queue.Queue)
1747 assert callable(fd.readline)
1748 threading.Thread.__init__(self)
1753 '''The body of the tread: read lines and put them on the queue.'''
1754 for line in iter(self._fd.readline, ''):
1755 self._queue.put(line)
1758 '''Check whether there is no more content to expect.'''
1759 return not self.is_alive() and self._queue.empty()
1767 def SdkToolsPopen(commands, cwd=None, output=True):
1769 if cmd not in config:
1770 config[cmd] = find_sdk_tools_cmd(commands[0])
1771 return FDroidPopen([config[cmd]] + commands[1:],
1772 cwd=cwd, output=output)
1775 def FDroidPopen(commands, cwd=None, output=True):
1777 Run a command and capture the possibly huge output.
1779 :param commands: command and argument list like in subprocess.Popen
1780 :param cwd: optionally specifies a working directory
1781 :returns: A PopenResult.
1787 cwd = os.path.normpath(cwd)
1788 logging.debug("Directory: %s" % cwd)
1789 logging.debug("> %s" % ' '.join(commands))
1791 result = PopenResult()
1794 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1795 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1797 raise BuildException("OSError while trying to execute " +
1798 ' '.join(commands) + ': ' + str(e))
1800 stdout_queue = Queue.Queue()
1801 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1802 stdout_reader.start()
1804 # Check the queue for output (until there is no more to get)
1805 while not stdout_reader.eof():
1806 while not stdout_queue.empty():
1807 line = stdout_queue.get()
1808 if output and options.verbose:
1809 # Output directly to console
1810 sys.stderr.write(line)
1812 result.output += line
1816 result.returncode = p.wait()
1820 def remove_signing_keys(build_dir):
1821 comment = re.compile(r'[ ]*//')
1822 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1824 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1825 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1826 re.compile(r'.*variant\.outputFile = .*'),
1827 re.compile(r'.*output\.outputFile = .*'),
1828 re.compile(r'.*\.readLine\(.*'),
1830 for root, dirs, files in os.walk(build_dir):
1831 if 'build.gradle' in files:
1832 path = os.path.join(root, 'build.gradle')
1834 with open(path, "r") as o:
1835 lines = o.readlines()
1841 with open(path, "w") as o:
1842 while i < len(lines):
1845 while line.endswith('\\\n'):
1846 line = line.rstrip('\\\n') + lines[i]
1849 if comment.match(line):
1853 opened += line.count('{')
1854 opened -= line.count('}')
1857 if signing_configs.match(line):
1862 if any(s.match(line) for s in line_matches):
1870 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1873 'project.properties',
1875 'default.properties',
1876 'ant.properties', ]:
1877 if propfile in files:
1878 path = os.path.join(root, propfile)
1880 with open(path, "r") as o:
1881 lines = o.readlines()
1885 with open(path, "w") as o:
1887 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1894 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1897 def reset_env_path():
1898 global env, orig_path
1899 env['PATH'] = orig_path
1902 def add_to_env_path(path):
1904 paths = env['PATH'].split(os.pathsep)
1908 env['PATH'] = os.pathsep.join(paths)
1911 def replace_config_vars(cmd, build):
1913 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1914 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1915 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1916 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1917 if build is not None:
1918 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1919 cmd = cmd.replace('$$VERSION$$', build['version'])
1920 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1924 def place_srclib(root_dir, number, libpath):
1927 relpath = os.path.relpath(libpath, root_dir)
1928 proppath = os.path.join(root_dir, 'project.properties')
1931 if os.path.isfile(proppath):
1932 with open(proppath, "r") as o:
1933 lines = o.readlines()
1935 with open(proppath, "w") as o:
1938 if line.startswith('android.library.reference.%d=' % number):
1939 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1944 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1947 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1948 """Verify that two apks are the same
1950 One of the inputs is signed, the other is unsigned. The signature metadata
1951 is transferred from the signed to the unsigned apk, and then jarsigner is
1952 used to verify that the signature from the signed apk is also varlid for
1954 :param signed_apk: Path to a signed apk file
1955 :param unsigned_apk: Path to an unsigned apk file expected to match it
1956 :param tmp_dir: Path to directory for temporary files
1957 :returns: None if the verification is successful, otherwise a string
1958 describing what went wrong.
1960 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1961 with ZipFile(signed_apk) as signed_apk_as_zip:
1962 meta_inf_files = ['META-INF/MANIFEST.MF']
1963 for f in signed_apk_as_zip.namelist():
1964 if sigfile.match(f):
1965 meta_inf_files.append(f)
1966 if len(meta_inf_files) < 3:
1967 return "Signature files missing from {0}".format(signed_apk)
1968 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1969 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1970 for meta_inf_file in meta_inf_files:
1971 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1973 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1974 logging.info("...NOT verified - {0}".format(signed_apk))
1975 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1976 logging.info("...successfully verified")
1980 def compare_apks(apk1, apk2, tmp_dir):
1983 Returns None if the apk content is the same (apart from the signing key),
1984 otherwise a string describing what's different, or what went wrong when
1985 trying to do the comparison.
1988 badchars = re.compile('''[/ :;'"]''')
1989 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1990 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1991 for d in [apk1dir, apk2dir]:
1992 if os.path.exists(d):
1995 os.mkdir(os.path.join(d, 'jar-xf'))
1997 if subprocess.call(['jar', 'xf',
1998 os.path.abspath(apk1)],
1999 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2000 return("Failed to unpack " + apk1)
2001 if subprocess.call(['jar', 'xf',
2002 os.path.abspath(apk2)],
2003 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2004 return("Failed to unpack " + apk2)
2006 # try to find apktool in the path, if it hasn't been manually configed
2007 if 'apktool' not in config:
2008 tmp = find_command('apktool')
2010 config['apktool'] = tmp
2011 if 'apktool' in config:
2012 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2014 return("Failed to unpack " + apk1)
2015 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2017 return("Failed to unpack " + apk2)
2019 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2020 lines = p.output.splitlines()
2021 if len(lines) != 1 or 'META-INF' not in lines[0]:
2022 meld = find_command('meld')
2023 if meld is not None:
2024 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2025 return("Unexpected diff output - " + p.output)
2027 # since everything verifies, delete the comparison to keep cruft down
2028 shutil.rmtree(apk1dir)
2029 shutil.rmtree(apk2dir)
2031 # If we get here, it seems like they're the same!
2035 def find_command(command):
2036 '''find the full path of a command, or None if it can't be found in the PATH'''
2039 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2041 fpath, fname = os.path.split(command)
2046 for path in os.environ["PATH"].split(os.pathsep):
2047 path = path.strip('"')
2048 exe_file = os.path.join(path, command)
2049 if is_exe(exe_file):
2056 '''generate a random password for when generating keys'''
2057 h = hashlib.sha256()
2058 h.update(os.urandom(16)) # salt
2059 h.update(bytes(socket.getfqdn()))
2060 return h.digest().encode('base64').strip()
2063 def genkeystore(localconfig):
2064 '''Generate a new key with random passwords and add it to new keystore'''
2065 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2066 keystoredir = os.path.dirname(localconfig['keystore'])
2067 if keystoredir is None or keystoredir == '':
2068 keystoredir = os.path.join(os.getcwd(), keystoredir)
2069 if not os.path.exists(keystoredir):
2070 os.makedirs(keystoredir, mode=0o700)
2072 write_password_file("keystorepass", localconfig['keystorepass'])
2073 write_password_file("keypass", localconfig['keypass'])
2074 p = FDroidPopen(['keytool', '-genkey',
2075 '-keystore', localconfig['keystore'],
2076 '-alias', localconfig['repo_keyalias'],
2077 '-keyalg', 'RSA', '-keysize', '4096',
2078 '-sigalg', 'SHA256withRSA',
2079 '-validity', '10000',
2080 '-storepass:file', config['keystorepassfile'],
2081 '-keypass:file', config['keypassfile'],
2082 '-dname', localconfig['keydname']])
2083 # TODO keypass should be sent via stdin
2084 if p.returncode != 0:
2085 raise BuildException("Failed to generate key", p.output)
2086 os.chmod(localconfig['keystore'], 0o0600)
2087 # now show the lovely key that was just generated
2088 p = FDroidPopen(['keytool', '-list', '-v',
2089 '-keystore', localconfig['keystore'],
2090 '-alias', localconfig['repo_keyalias'],
2091 '-storepass:file', config['keystorepassfile']])
2092 logging.info(p.output.strip() + '\n\n')
2095 def write_to_config(thisconfig, key, value=None):
2096 '''write a key/value to the local config.py'''
2098 origkey = key + '_orig'
2099 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2100 with open('config.py', 'r') as f:
2102 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2103 repl = '\n' + key + ' = "' + value + '"'
2104 data = re.sub(pattern, repl, data)
2105 # if this key is not in the file, append it
2106 if not re.match('\s*' + key + '\s*=\s*"', data):
2108 # make sure the file ends with a carraige return
2109 if not re.match('\n$', data):
2111 with open('config.py', 'w') as f:
2115 def parse_xml(path):
2116 return XMLElementTree.parse(path).getroot()
2119 def string_is_integer(string):
2127 def download_file(url, local_filename=None, dldir='tmp'):
2128 filename = url.split('/')[-1]
2129 if local_filename is None:
2130 local_filename = os.path.join(dldir, filename)
2131 # the stream=True parameter keeps memory usage low
2132 r = requests.get(url, stream=True)
2133 with open(local_filename, 'wb') as f:
2134 for chunk in r.iter_content(chunk_size=1024):
2135 if chunk: # filter out keep-alive new chunks
2138 return local_filename
2141 def get_per_app_repos():
2142 '''per-app repos are dirs named with the packageName of a single app'''
2144 # Android packageNames are Java packages, they may contain uppercase or
2145 # lowercase letters ('A' through 'Z'), numbers, and underscores
2146 # ('_'). However, individual package name parts may only start with
2147 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2148 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2151 for root, dirs, files in os.walk(os.getcwd()):
2153 print 'checking', root, 'for', d
2154 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2155 # standard parts of an fdroid repo, so never packageNames
2158 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):