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/>.
33 from distutils.version import LooseVersion
44 'sdk_path': "$ANDROID_HOME",
47 'r10d': "$ANDROID_NDK"
49 'build_tools': "21.1.2",
53 'sync_from_local_copy_dir': False,
54 'make_current_version_link': True,
55 'current_version_name_source': 'Name',
56 'update_stats': False,
60 'stats_to_carbon': False,
62 'build_server_always': False,
63 'keystore': os.path.join("$HOME", '.local', 'share', 'fdroidserver', 'keystore.jks'),
64 'smartcardoptions': [],
70 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
71 'repo_name': "My First FDroid Repo Demo",
72 'repo_icon': "fdroid-icon.png",
73 'repo_description': '''
74 This is a repository of apps to be used with FDroid. Applications in this
75 repository are either official binaries built by the original application
76 developers, or are binaries built from source by the admin of f-droid.org
77 using the tools on https://gitlab.com/u/fdroid.
83 def fill_config_defaults(thisconfig):
84 for k, v in default_config.items():
85 if k not in thisconfig:
88 # Expand paths (~users and $vars)
89 def expand_path(path):
93 path = os.path.expanduser(path)
94 path = os.path.expandvars(path)
99 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
104 thisconfig[k + '_orig'] = v
106 for k in ['ndk_paths']:
112 thisconfig[k][k2] = exp
113 thisconfig[k][k2 + '_orig'] = v
116 def read_config(opts, config_file='config.py'):
117 """Read the repository config
119 The config is read from config_file, which is in the current directory when
120 any of the repo management commands are used.
122 global config, options, env, orig_path
124 if config is not None:
126 if not os.path.isfile(config_file):
127 logging.critical("Missing config file - is this a repo directory?")
134 logging.debug("Reading %s" % config_file)
135 execfile(config_file, config)
137 # smartcardoptions must be a list since its command line args for Popen
138 if 'smartcardoptions' in config:
139 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
140 elif 'keystore' in config and config['keystore'] == 'NONE':
141 # keystore='NONE' means use smartcard, these are required defaults
142 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
143 'SunPKCS11-OpenSC', '-providerClass',
144 'sun.security.pkcs11.SunPKCS11',
145 '-providerArg', 'opensc-fdroid.cfg']
147 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
148 st = os.stat(config_file)
149 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
150 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
152 fill_config_defaults(config)
154 # There is no standard, so just set up the most common environment
157 orig_path = env['PATH']
158 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
159 env[n] = config['sdk_path']
161 for k in ["keystorepass", "keypass"]:
163 write_password_file(k)
165 for k in ["repo_description", "archive_description"]:
167 config[k] = clean_description(config[k])
169 if 'serverwebroot' in config:
170 if isinstance(config['serverwebroot'], basestring):
171 roots = [config['serverwebroot']]
172 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
173 roots = config['serverwebroot']
175 raise TypeError('only accepts strings, lists, and tuples')
177 for rootstr in roots:
178 # since this is used with rsync, where trailing slashes have
179 # meaning, ensure there is always a trailing slash
180 if rootstr[-1] != '/':
182 rootlist.append(rootstr.replace('//', '/'))
183 config['serverwebroot'] = rootlist
188 def get_ndk_path(version):
190 version = 'r10d' # latest
191 paths = config['ndk_paths']
192 if version not in paths:
194 return paths[version] or ''
197 def find_sdk_tools_cmd(cmd):
198 '''find a working path to a tool from the Android SDK'''
201 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
202 # try to find a working path to this command, in all the recent possible paths
203 if 'build_tools' in config:
204 build_tools = os.path.join(config['sdk_path'], 'build-tools')
205 # if 'build_tools' was manually set and exists, check only that one
206 configed_build_tools = os.path.join(build_tools, config['build_tools'])
207 if os.path.exists(configed_build_tools):
208 tooldirs.append(configed_build_tools)
210 # no configed version, so hunt known paths for it
211 for f in sorted(os.listdir(build_tools), reverse=True):
212 if os.path.isdir(os.path.join(build_tools, f)):
213 tooldirs.append(os.path.join(build_tools, f))
214 tooldirs.append(build_tools)
215 sdk_tools = os.path.join(config['sdk_path'], 'tools')
216 if os.path.exists(sdk_tools):
217 tooldirs.append(sdk_tools)
218 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
219 if os.path.exists(sdk_platform_tools):
220 tooldirs.append(sdk_platform_tools)
221 tooldirs.append('/usr/bin')
223 if os.path.isfile(os.path.join(d, cmd)):
224 return os.path.join(d, cmd)
225 # did not find the command, exit with error message
226 ensure_build_tools_exists(config)
229 def test_sdk_exists(thisconfig):
230 if 'sdk_path' not in thisconfig:
231 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
234 logging.error("'sdk_path' not set in config.py!")
236 if thisconfig['sdk_path'] == default_config['sdk_path']:
237 logging.error('No Android SDK found!')
238 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
239 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
241 if not os.path.exists(thisconfig['sdk_path']):
242 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
244 if not os.path.isdir(thisconfig['sdk_path']):
245 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
247 for d in ['build-tools', 'platform-tools', 'tools']:
248 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
249 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
250 thisconfig['sdk_path'], d))
255 def ensure_build_tools_exists(thisconfig):
256 if not test_sdk_exists(thisconfig):
258 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
259 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
260 if not os.path.isdir(versioned_build_tools):
261 logging.critical('Android Build Tools path "'
262 + versioned_build_tools + '" does not exist!')
266 def write_password_file(pwtype, password=None):
268 writes out passwords to a protected file instead of passing passwords as
269 command line argments
271 filename = '.fdroid.' + pwtype + '.txt'
272 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
274 os.write(fd, config[pwtype])
276 os.write(fd, password)
278 config[pwtype + 'file'] = filename
281 # Given the arguments in the form of multiple appid:[vc] strings, this returns
282 # a dictionary with the set of vercodes specified for each package.
283 def read_pkg_args(args, allow_vercodes=False):
290 if allow_vercodes and ':' in p:
291 package, vercode = p.split(':')
293 package, vercode = p, None
294 if package not in vercodes:
295 vercodes[package] = [vercode] if vercode else []
297 elif vercode and vercode not in vercodes[package]:
298 vercodes[package] += [vercode] if vercode else []
303 # On top of what read_pkg_args does, this returns the whole app metadata, but
304 # limiting the builds list to the builds matching the vercodes specified.
305 def read_app_args(args, allapps, allow_vercodes=False):
307 vercodes = read_pkg_args(args, allow_vercodes)
313 for appid, app in allapps.iteritems():
314 if appid in vercodes:
317 if len(apps) != len(vercodes):
320 logging.critical("No such package: %s" % p)
321 raise FDroidException("Found invalid app ids in arguments")
323 raise FDroidException("No packages specified")
326 for appid, app in apps.iteritems():
330 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
331 if len(app['builds']) != len(vercodes[appid]):
333 allvcs = [b['vercode'] for b in app['builds']]
334 for v in vercodes[appid]:
336 logging.critical("No such vercode %s for app %s" % (v, appid))
339 raise FDroidException("Found invalid vercodes for some apps")
344 def has_extension(filename, extension):
345 name, ext = os.path.splitext(filename)
346 ext = ext.lower()[1:]
347 return ext == extension
352 def clean_description(description):
353 'Remove unneeded newlines and spaces from a block of description text'
355 # this is split up by paragraph to make removing the newlines easier
356 for paragraph in re.split(r'\n\n', description):
357 paragraph = re.sub('\r', '', paragraph)
358 paragraph = re.sub('\n', ' ', paragraph)
359 paragraph = re.sub(' {2,}', ' ', paragraph)
360 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
361 returnstring += paragraph + '\n\n'
362 return returnstring.rstrip('\n')
365 def apknameinfo(filename):
367 filename = os.path.basename(filename)
368 if apk_regex is None:
369 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
370 m = apk_regex.match(filename)
372 result = (m.group(1), m.group(2))
373 except AttributeError:
374 raise FDroidException("Invalid apk name: %s" % filename)
378 def getapkname(app, build):
379 return "%s_%s.apk" % (app['id'], build['vercode'])
382 def getsrcname(app, build):
383 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
390 return app['Auto Name']
395 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
398 def getvcs(vcstype, remote, local):
400 return vcs_git(remote, local)
401 if vcstype == 'git-svn':
402 return vcs_gitsvn(remote, local)
404 return vcs_hg(remote, local)
406 return vcs_bzr(remote, local)
407 if vcstype == 'srclib':
408 if local != os.path.join('build', 'srclib', remote):
409 raise VCSException("Error: srclib paths are hard-coded!")
410 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
412 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
413 raise VCSException("Invalid vcs type " + vcstype)
416 def getsrclibvcs(name):
417 if name not in metadata.srclibs:
418 raise VCSException("Missing srclib " + name)
419 return metadata.srclibs[name]['Repo Type']
424 def __init__(self, remote, local):
426 # svn, git-svn and bzr may require auth
428 if self.repotype() in ('git-svn', 'bzr'):
430 if self.repotype == 'git-svn':
431 raise VCSException("Authentication is not supported for git-svn")
432 self.username, remote = remote.split('@')
433 if ':' not in self.username:
434 raise VCSException("Password required with username")
435 self.username, self.password = self.username.split(':')
439 self.clone_failed = False
440 self.refreshed = False
446 # Take the local repository to a clean version of the given revision, which
447 # is specificed in the VCS's native format. Beforehand, the repository can
448 # be dirty, or even non-existent. If the repository does already exist
449 # locally, it will be updated from the origin, but only once in the
450 # lifetime of the vcs object.
451 # None is acceptable for 'rev' if you know you are cloning a clean copy of
452 # the repo - otherwise it must specify a valid revision.
453 def gotorevision(self, rev):
455 if self.clone_failed:
456 raise VCSException("Downloading the repository already failed once, not trying again.")
458 # The .fdroidvcs-id file for a repo tells us what VCS type
459 # and remote that directory was created from, allowing us to drop it
460 # automatically if either of those things changes.
461 fdpath = os.path.join(self.local, '..',
462 '.fdroidvcs-' + os.path.basename(self.local))
463 cdata = self.repotype() + ' ' + self.remote
466 if os.path.exists(self.local):
467 if os.path.exists(fdpath):
468 with open(fdpath, 'r') as f:
469 fsdata = f.read().strip()
474 logging.info("Repository details for %s changed - deleting" % (
478 logging.info("Repository details for %s missing - deleting" % (
481 shutil.rmtree(self.local)
486 self.gotorevisionx(rev)
487 except FDroidException, e:
490 # If necessary, write the .fdroidvcs file.
491 if writeback and not self.clone_failed:
492 with open(fdpath, 'w') as f:
498 # Derived classes need to implement this. It's called once basic checking
499 # has been performend.
500 def gotorevisionx(self, rev):
501 raise VCSException("This VCS type doesn't define gotorevisionx")
503 # Initialise and update submodules
504 def initsubmodules(self):
505 raise VCSException('Submodules not supported for this vcs type')
507 # Get a list of all known tags
509 if not self._gettags:
510 raise VCSException('gettags not supported for this vcs type')
512 for tag in self._gettags():
513 if re.match('[-A-Za-z0-9_. ]+$', tag):
517 # Get a list of latest number tags
518 def latesttags(self, number):
519 raise VCSException('latesttags not supported for this vcs type')
521 # Get current commit reference (hash, revision, etc)
523 raise VCSException('getref not supported for this vcs type')
525 # Returns the srclib (name, path) used in setting up the current
536 # If the local directory exists, but is somehow not a git repository, git
537 # will traverse up the directory tree until it finds one that is (i.e.
538 # fdroidserver) and then we'll proceed to destroy it! This is called as
541 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
542 result = p.output.rstrip()
543 if not result.endswith(self.local):
544 raise VCSException('Repository mismatch')
546 def gotorevisionx(self, rev):
547 if not os.path.exists(self.local):
549 p = FDroidPopen(['git', 'clone', self.remote, self.local])
550 if p.returncode != 0:
551 self.clone_failed = True
552 raise VCSException("Git clone failed", p.output)
556 # Discard any working tree changes
557 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
558 if p.returncode != 0:
559 raise VCSException("Git reset failed", p.output)
560 # Remove untracked files now, in case they're tracked in the target
561 # revision (it happens!)
562 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
563 if p.returncode != 0:
564 raise VCSException("Git clean failed", p.output)
565 if not self.refreshed:
566 # Get latest commits and tags from remote
567 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
568 if p.returncode != 0:
569 raise VCSException("Git fetch failed", p.output)
570 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
571 if p.returncode != 0:
572 raise VCSException("Git fetch failed", p.output)
573 # Recreate origin/HEAD as git clone would do it, in case it disappeared
574 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
575 if p.returncode != 0:
576 lines = p.output.splitlines()
577 if 'Multiple remote HEAD branches' not in lines[0]:
578 raise VCSException("Git remote set-head failed", p.output)
579 branch = lines[1].split(' ')[-1]
580 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
581 if p2.returncode != 0:
582 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
583 self.refreshed = True
584 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
585 # a github repo. Most of the time this is the same as origin/master.
586 rev = rev or 'origin/HEAD'
587 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
588 if p.returncode != 0:
589 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
590 # Get rid of any uncontrolled files left behind
591 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
592 if p.returncode != 0:
593 raise VCSException("Git clean failed", p.output)
595 def initsubmodules(self):
597 submfile = os.path.join(self.local, '.gitmodules')
598 if not os.path.isfile(submfile):
599 raise VCSException("No git submodules available")
601 # fix submodules not accessible without an account and public key auth
602 with open(submfile, 'r') as f:
603 lines = f.readlines()
604 with open(submfile, 'w') as f:
606 if 'git@github.com' in line:
607 line = line.replace('git@github.com:', 'https://github.com/')
611 ['git', 'reset', '--hard'],
612 ['git', 'clean', '-dffx'],
614 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
615 if p.returncode != 0:
616 raise VCSException("Git submodule reset failed", p.output)
617 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
618 if p.returncode != 0:
619 raise VCSException("Git submodule sync failed", p.output)
620 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
621 if p.returncode != 0:
622 raise VCSException("Git submodule update failed", p.output)
626 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
627 return p.output.splitlines()
629 def latesttags(self, alltags, number):
631 p = FDroidPopen(['echo "' + '\n'.join(alltags) + '" | '
633 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
634 + 'sort -n | awk \'{print $2}\''],
635 cwd=self.local, shell=True, output=False)
636 return p.output.splitlines()[-number:]
639 class vcs_gitsvn(vcs):
644 # If the local directory exists, but is somehow not a git repository, git
645 # will traverse up the directory tree until it finds one that is (i.e.
646 # fdroidserver) and then we'll proceed to destory it! This is called as
649 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
650 result = p.output.rstrip()
651 if not result.endswith(self.local):
652 raise VCSException('Repository mismatch')
654 def gotorevisionx(self, rev):
655 if not os.path.exists(self.local):
657 gitsvn_args = ['git', 'svn', 'clone']
658 if ';' in self.remote:
659 remote_split = self.remote.split(';')
660 for i in remote_split[1:]:
661 if i.startswith('trunk='):
662 gitsvn_args.extend(['-T', i[6:]])
663 elif i.startswith('tags='):
664 gitsvn_args.extend(['-t', i[5:]])
665 elif i.startswith('branches='):
666 gitsvn_args.extend(['-b', i[9:]])
667 gitsvn_args.extend([remote_split[0], self.local])
668 p = FDroidPopen(gitsvn_args, output=False)
669 if p.returncode != 0:
670 self.clone_failed = True
671 raise VCSException("Git svn clone failed", p.output)
673 gitsvn_args.extend([self.remote, self.local])
674 p = FDroidPopen(gitsvn_args, output=False)
675 if p.returncode != 0:
676 self.clone_failed = True
677 raise VCSException("Git svn clone failed", p.output)
681 # Discard any working tree changes
682 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
683 if p.returncode != 0:
684 raise VCSException("Git reset failed", p.output)
685 # Remove untracked files now, in case they're tracked in the target
686 # revision (it happens!)
687 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
688 if p.returncode != 0:
689 raise VCSException("Git clean failed", p.output)
690 if not self.refreshed:
691 # Get new commits, branches and tags from repo
692 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
693 if p.returncode != 0:
694 raise VCSException("Git svn fetch failed")
695 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
696 if p.returncode != 0:
697 raise VCSException("Git svn rebase failed", p.output)
698 self.refreshed = True
700 rev = rev or 'master'
702 nospaces_rev = rev.replace(' ', '%20')
703 # Try finding a svn tag
704 for treeish in ['origin/', '']:
705 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
706 if p.returncode == 0:
708 if p.returncode != 0:
709 # No tag found, normal svn rev translation
710 # Translate svn rev into git format
711 rev_split = rev.split('/')
714 for treeish in ['origin/', '']:
715 if len(rev_split) > 1:
716 treeish += rev_split[0]
717 svn_rev = rev_split[1]
720 # if no branch is specified, then assume trunk (i.e. 'master' branch):
724 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
726 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
727 git_rev = p.output.rstrip()
729 if p.returncode == 0 and git_rev:
732 if p.returncode != 0 or not git_rev:
733 # Try a plain git checkout as a last resort
734 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
735 if p.returncode != 0:
736 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
738 # Check out the git rev equivalent to the svn rev
739 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
740 if p.returncode != 0:
741 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
743 # Get rid of any uncontrolled files left behind
744 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
745 if p.returncode != 0:
746 raise VCSException("Git clean failed", p.output)
750 for treeish in ['origin/', '']:
751 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
757 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
758 if p.returncode != 0:
760 return p.output.strip()
768 def gotorevisionx(self, rev):
769 if not os.path.exists(self.local):
770 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
771 if p.returncode != 0:
772 self.clone_failed = True
773 raise VCSException("Hg clone failed", p.output)
775 p = FDroidPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True, output=False)
776 if p.returncode != 0:
777 raise VCSException("Hg clean failed", p.output)
778 if not self.refreshed:
779 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
780 if p.returncode != 0:
781 raise VCSException("Hg pull failed", p.output)
782 self.refreshed = True
784 rev = rev or 'default'
787 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
788 if p.returncode != 0:
789 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
790 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
791 # Also delete untracked files, we have to enable purge extension for that:
792 if "'purge' is provided by the following extension" in p.output:
793 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
794 myfile.write("\n[extensions]\nhgext.purge=\n")
795 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
796 if p.returncode != 0:
797 raise VCSException("HG purge failed", p.output)
798 elif p.returncode != 0:
799 raise VCSException("HG purge failed", p.output)
802 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
803 return p.output.splitlines()[1:]
811 def gotorevisionx(self, rev):
812 if not os.path.exists(self.local):
813 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
814 if p.returncode != 0:
815 self.clone_failed = True
816 raise VCSException("Bzr branch failed", p.output)
818 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
819 if p.returncode != 0:
820 raise VCSException("Bzr revert failed", p.output)
821 if not self.refreshed:
822 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
823 if p.returncode != 0:
824 raise VCSException("Bzr update failed", p.output)
825 self.refreshed = True
827 revargs = list(['-r', rev] if rev else [])
828 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
829 if p.returncode != 0:
830 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
833 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
834 return [tag.split(' ')[0].strip() for tag in
835 p.output.splitlines()]
838 def retrieve_string(app_dir, string, xmlfiles=None):
841 os.path.join(app_dir, 'res'),
842 os.path.join(app_dir, 'src', 'main'),
847 for res_dir in res_dirs:
848 for r, d, f in os.walk(res_dir):
849 if os.path.basename(r) == 'values':
850 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
853 if string.startswith('@string/'):
854 string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
855 elif string.startswith('&') and string.endswith(';'):
856 string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
858 if string_search is not None:
859 for xmlfile in xmlfiles:
860 for line in file(xmlfile):
861 matches = string_search(line)
863 return retrieve_string(app_dir, matches.group(1), xmlfiles)
866 return string.replace("\\'", "'")
869 # Return list of existing files that will be used to find the highest vercode
870 def manifest_paths(app_dir, flavours):
872 possible_manifests = \
873 [os.path.join(app_dir, 'AndroidManifest.xml'),
874 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
875 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
876 os.path.join(app_dir, 'build.gradle')]
878 for flavour in flavours:
881 possible_manifests.append(
882 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
884 return [path for path in possible_manifests if os.path.isfile(path)]
887 # Retrieve the package name. Returns the name, or None if not found.
888 def fetch_real_name(app_dir, flavours):
889 app_search = re.compile(r'.*<application.*').search
890 name_search = re.compile(r'.*android:label="([^"]+)".*').search
892 for f in manifest_paths(app_dir, flavours):
893 if not has_extension(f, 'xml'):
895 logging.debug("fetch_real_name: Checking manifest at " + f)
901 matches = name_search(line)
903 stringname = matches.group(1)
904 logging.debug("fetch_real_name: using string " + stringname)
905 result = retrieve_string(app_dir, stringname)
907 result = result.strip()
912 # Retrieve the version name
913 def version_name(original, app_dir, flavours):
914 for f in manifest_paths(app_dir, flavours):
915 if not has_extension(f, 'xml'):
917 string = retrieve_string(app_dir, original)
923 def get_library_references(root_dir):
925 proppath = os.path.join(root_dir, 'project.properties')
926 if not os.path.isfile(proppath):
928 with open(proppath) as f:
929 for line in f.readlines():
930 if not line.startswith('android.library.reference.'):
932 path = line.split('=')[1].strip()
933 relpath = os.path.join(root_dir, path)
934 if not os.path.isdir(relpath):
936 logging.debug("Found subproject at %s" % path)
937 libraries.append(path)
941 def ant_subprojects(root_dir):
942 subprojects = get_library_references(root_dir)
943 for subpath in subprojects:
944 subrelpath = os.path.join(root_dir, subpath)
945 for p in get_library_references(subrelpath):
946 relp = os.path.normpath(os.path.join(subpath, p))
947 if relp not in subprojects:
948 subprojects.insert(0, relp)
952 def remove_debuggable_flags(root_dir):
953 # Remove forced debuggable flags
954 logging.debug("Removing debuggable flags from %s" % root_dir)
955 for root, dirs, files in os.walk(root_dir):
956 if 'AndroidManifest.xml' in files:
957 path = os.path.join(root, 'AndroidManifest.xml')
958 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
959 if p.returncode != 0:
960 raise BuildException("Failed to remove debuggable flags of %s" % path)
963 # Extract some information from the AndroidManifest.xml at the given path.
964 # Returns (version, vercode, package), any or all of which might be None.
965 # All values returned are strings.
966 def parse_androidmanifests(paths, ignoreversions=None):
969 return (None, None, None)
971 vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
972 vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
973 psearch = re.compile(r'.*package="([^"]+)".*').search
975 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
976 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
977 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
979 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
987 logging.debug("Parsing manifest at {0}".format(path))
988 gradle = has_extension(path, 'gradle')
991 # Remember package name, may be defined separately from version+vercode
992 package = max_package
994 for line in file(path):
997 matches = psearch_g(line)
999 matches = psearch(line)
1001 package = matches.group(1)
1004 matches = vnsearch_g(line)
1006 matches = vnsearch(line)
1008 version = matches.group(2 if gradle else 1)
1011 matches = vcsearch_g(line)
1013 matches = vcsearch(line)
1015 vercode = matches.group(1)
1017 logging.debug("..got package={0}, version={1}, vercode={2}"
1018 .format(package, version, vercode))
1020 # Always grab the package name and version name in case they are not
1021 # together with the highest version code
1022 if max_package is None and package is not None:
1023 max_package = package
1024 if max_version is None and version is not None:
1025 max_version = version
1027 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1028 if not ignoresearch or not ignoresearch(version):
1029 if version is not None:
1030 max_version = version
1031 if vercode is not None:
1032 max_vercode = vercode
1033 if package is not None:
1034 max_package = package
1036 max_version = "Ignore"
1038 if max_version is None:
1039 max_version = "Unknown"
1041 return (max_version, max_vercode, max_package)
1044 class FDroidException(Exception):
1046 def __init__(self, value, detail=None):
1048 self.detail = detail
1050 def get_wikitext(self):
1051 ret = repr(self.value) + "\n"
1055 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1063 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1067 class VCSException(FDroidException):
1071 class BuildException(FDroidException):
1075 # Get the specified source library.
1076 # Returns the path to it. Normally this is the path to be used when referencing
1077 # it, which may be a subdirectory of the actual project. If you want the base
1078 # directory of the project, pass 'basepath=True'.
1079 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1080 basepath=False, raw=False, prepare=True, preponly=False):
1088 name, ref = spec.split('@')
1090 number, name = name.split(':', 1)
1092 name, subdir = name.split('/', 1)
1094 if name not in metadata.srclibs:
1095 raise VCSException('srclib ' + name + ' not found.')
1097 srclib = metadata.srclibs[name]
1099 sdir = os.path.join(srclib_dir, name)
1102 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1103 vcs.srclib = (name, number, sdir)
1105 vcs.gotorevision(ref)
1112 libdir = os.path.join(sdir, subdir)
1113 elif srclib["Subdir"]:
1114 for subdir in srclib["Subdir"]:
1115 libdir_candidate = os.path.join(sdir, subdir)
1116 if os.path.exists(libdir_candidate):
1117 libdir = libdir_candidate
1123 if srclib["Srclibs"]:
1125 for lib in srclib["Srclibs"].replace(';', ',').split(','):
1127 for t in srclibpaths:
1132 raise VCSException('Missing recursive srclib %s for %s' % (
1134 place_srclib(libdir, n, s_tuple[2])
1137 remove_signing_keys(sdir)
1138 remove_debuggable_flags(sdir)
1142 if srclib["Prepare"]:
1143 cmd = replace_config_vars(srclib["Prepare"])
1145 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1146 if p.returncode != 0:
1147 raise BuildException("Error running prepare command for srclib %s"
1153 return (name, number, libdir)
1156 # Prepare the source code for a particular build
1157 # 'vcs' - the appropriate vcs object for the application
1158 # 'app' - the application details from the metadata
1159 # 'build' - the build details from the metadata
1160 # 'build_dir' - the path to the build directory, usually
1162 # 'srclib_dir' - the path to the source libraries directory, usually
1164 # 'extlib_dir' - the path to the external libraries directory, usually
1166 # Returns the (root, srclibpaths) where:
1167 # 'root' is the root directory, which may be the same as 'build_dir' or may
1168 # be a subdirectory of it.
1169 # 'srclibpaths' is information on the srclibs being used
1170 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1172 # Optionally, the actual app source can be in a subdirectory
1174 root_dir = os.path.join(build_dir, build['subdir'])
1176 root_dir = build_dir
1178 # Get a working copy of the right revision
1179 logging.info("Getting source for revision " + build['commit'])
1180 vcs.gotorevision(build['commit'])
1182 # Initialise submodules if requred
1183 if build['submodules']:
1184 logging.info("Initialising submodules")
1185 vcs.initsubmodules()
1187 # Check that a subdir (if we're using one) exists. This has to happen
1188 # after the checkout, since it might not exist elsewhere
1189 if not os.path.exists(root_dir):
1190 raise BuildException('Missing subdir ' + root_dir)
1192 # Run an init command if one is required
1194 cmd = replace_config_vars(build['init'])
1195 logging.info("Running 'init' commands in %s" % root_dir)
1197 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1198 if p.returncode != 0:
1199 raise BuildException("Error running init command for %s:%s" %
1200 (app['id'], build['version']), p.output)
1202 # Apply patches if any
1204 logging.info("Applying patches")
1205 for patch in build['patch']:
1206 patch = patch.strip()
1207 logging.info("Applying " + patch)
1208 patch_path = os.path.join('metadata', app['id'], patch)
1209 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1210 if p.returncode != 0:
1211 raise BuildException("Failed to apply patch %s" % patch_path)
1213 # Get required source libraries
1215 if build['srclibs']:
1216 logging.info("Collecting source libraries")
1217 for lib in build['srclibs']:
1218 srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1221 for name, number, libpath in srclibpaths:
1222 place_srclib(root_dir, int(number) if number else None, libpath)
1224 basesrclib = vcs.getsrclib()
1225 # If one was used for the main source, add that too.
1227 srclibpaths.append(basesrclib)
1229 # Update the local.properties file
1230 localprops = [os.path.join(build_dir, 'local.properties')]
1232 localprops += [os.path.join(root_dir, 'local.properties')]
1233 for path in localprops:
1235 if os.path.isfile(path):
1236 logging.info("Updating local.properties file at %s" % path)
1242 logging.info("Creating local.properties file at %s" % path)
1243 # Fix old-fashioned 'sdk-location' by copying
1244 # from sdk.dir, if necessary
1245 if build['oldsdkloc']:
1246 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1247 re.S | re.M).group(1)
1248 props += "sdk-location=%s\n" % sdkloc
1250 props += "sdk.dir=%s\n" % config['sdk_path']
1251 props += "sdk-location=%s\n" % config['sdk_path']
1252 if build['ndk_path']:
1254 props += "ndk.dir=%s\n" % build['ndk_path']
1255 props += "ndk-location=%s\n" % build['ndk_path']
1256 # Add java.encoding if necessary
1257 if build['encoding']:
1258 props += "java.encoding=%s\n" % build['encoding']
1264 if build['type'] == 'gradle':
1265 flavours = build['gradle']
1267 version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1268 gradlepluginver = None
1270 gradle_files = [os.path.join(root_dir, 'build.gradle')]
1272 # Parent dir build.gradle
1273 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1274 if parent_dir.startswith(build_dir):
1275 gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1277 for path in gradle_files:
1280 if not os.path.isfile(path):
1282 with open(path) as f:
1284 match = version_regex.match(line)
1286 gradlepluginver = match.group(1)
1290 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1292 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1293 build['gradlepluginver'] = LooseVersion('0.11')
1296 n = build["target"].split('-')[1]
1297 FDroidPopen(['sed', '-i',
1298 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1299 'build.gradle'], cwd=root_dir, output=False)
1301 # Remove forced debuggable flags
1302 remove_debuggable_flags(root_dir)
1304 # Insert version code and number into the manifest if necessary
1305 if build['forceversion']:
1306 logging.info("Changing the version name")
1307 for path in manifest_paths(root_dir, flavours):
1308 if not os.path.isfile(path):
1310 if has_extension(path, 'xml'):
1311 p = FDroidPopen(['sed', '-i',
1312 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1313 path], output=False)
1314 if p.returncode != 0:
1315 raise BuildException("Failed to amend manifest")
1316 elif has_extension(path, 'gradle'):
1317 p = FDroidPopen(['sed', '-i',
1318 's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1319 path], output=False)
1320 if p.returncode != 0:
1321 raise BuildException("Failed to amend build.gradle")
1322 if build['forcevercode']:
1323 logging.info("Changing the version code")
1324 for path in manifest_paths(root_dir, flavours):
1325 if not os.path.isfile(path):
1327 if has_extension(path, 'xml'):
1328 p = FDroidPopen(['sed', '-i',
1329 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1330 path], output=False)
1331 if p.returncode != 0:
1332 raise BuildException("Failed to amend manifest")
1333 elif has_extension(path, 'gradle'):
1334 p = FDroidPopen(['sed', '-i',
1335 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1336 path], output=False)
1337 if p.returncode != 0:
1338 raise BuildException("Failed to amend build.gradle")
1340 # Delete unwanted files
1342 logging.info("Removing specified files")
1343 for part in getpaths(build_dir, build, 'rm'):
1344 dest = os.path.join(build_dir, part)
1345 logging.info("Removing {0}".format(part))
1346 if os.path.lexists(dest):
1347 if os.path.islink(dest):
1348 FDroidPopen(['unlink ' + dest], shell=True, output=False)
1350 FDroidPopen(['rm -rf ' + dest], shell=True, output=False)
1352 logging.info("...but it didn't exist")
1354 remove_signing_keys(build_dir)
1356 # Add required external libraries
1357 if build['extlibs']:
1358 logging.info("Collecting prebuilt libraries")
1359 libsdir = os.path.join(root_dir, 'libs')
1360 if not os.path.exists(libsdir):
1362 for lib in build['extlibs']:
1364 logging.info("...installing extlib {0}".format(lib))
1365 libf = os.path.basename(lib)
1366 libsrc = os.path.join(extlib_dir, lib)
1367 if not os.path.exists(libsrc):
1368 raise BuildException("Missing extlib file {0}".format(libsrc))
1369 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1371 # Run a pre-build command if one is required
1372 if build['prebuild']:
1373 logging.info("Running 'prebuild' commands in %s" % root_dir)
1375 cmd = replace_config_vars(build['prebuild'])
1377 # Substitute source library paths into prebuild commands
1378 for name, number, libpath in srclibpaths:
1379 libpath = os.path.relpath(libpath, root_dir)
1380 cmd = cmd.replace('$$' + name + '$$', libpath)
1382 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1383 if p.returncode != 0:
1384 raise BuildException("Error running prebuild command for %s:%s" %
1385 (app['id'], build['version']), p.output)
1387 # Generate (or update) the ant build file, build.xml...
1388 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1389 parms = ['android', 'update', 'lib-project']
1390 lparms = ['android', 'update', 'project']
1393 parms += ['-t', build['target']]
1394 lparms += ['-t', build['target']]
1395 if build['update'] == ['auto']:
1396 update_dirs = ant_subprojects(root_dir) + ['.']
1398 update_dirs = build['update']
1400 for d in update_dirs:
1401 subdir = os.path.join(root_dir, d)
1403 logging.debug("Updating main project")
1404 cmd = parms + ['-p', d]
1406 logging.debug("Updating subproject %s" % d)
1407 cmd = lparms + ['-p', d]
1408 p = SdkToolsPopen(cmd, cwd=root_dir)
1409 # Check to see whether an error was returned without a proper exit
1410 # code (this is the case for the 'no target set or target invalid'
1412 if p.returncode != 0 or p.output.startswith("Error: "):
1413 raise BuildException("Failed to update project at %s" % d, p.output)
1414 # Clean update dirs via ant
1416 logging.info("Cleaning subproject %s" % d)
1417 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1419 return (root_dir, srclibpaths)
1422 # Split and extend via globbing the paths from a field
1423 def getpaths(build_dir, build, field):
1425 for p in build[field]:
1427 full_path = os.path.join(build_dir, p)
1428 full_path = os.path.normpath(full_path)
1429 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1433 # Scan the source code in the given directory (and all subdirectories)
1434 # and return the number of fatal problems encountered
1435 def scan_source(build_dir, root_dir, thisbuild):
1439 # Common known non-free blobs (always lower case):
1441 re.compile(r'flurryagent', re.IGNORECASE),
1442 re.compile(r'paypal.*mpl', re.IGNORECASE),
1443 re.compile(r'google.*analytics', re.IGNORECASE),
1444 re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1445 re.compile(r'google.*ad.*view', re.IGNORECASE),
1446 re.compile(r'google.*admob', re.IGNORECASE),
1447 re.compile(r'google.*play.*services', re.IGNORECASE),
1448 re.compile(r'crittercism', re.IGNORECASE),
1449 re.compile(r'heyzap', re.IGNORECASE),
1450 re.compile(r'jpct.*ae', re.IGNORECASE),
1451 re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1452 re.compile(r'bugsense', re.IGNORECASE),
1453 re.compile(r'crashlytics', re.IGNORECASE),
1454 re.compile(r'ouya.*sdk', re.IGNORECASE),
1455 re.compile(r'libspen23', re.IGNORECASE),
1458 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1459 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1461 scanignore_worked = set()
1462 scandelete_worked = set()
1465 ms = magic.open(magic.MIME_TYPE)
1467 except AttributeError:
1471 for p in scanignore:
1472 if fd.startswith(p):
1473 scanignore_worked.add(p)
1478 for p in scandelete:
1479 if fd.startswith(p):
1480 scandelete_worked.add(p)
1484 def ignoreproblem(what, fd, fp):
1485 logging.info('Ignoring %s at %s' % (what, fd))
1488 def removeproblem(what, fd, fp):
1489 logging.info('Removing %s at %s' % (what, fd))
1493 def warnproblem(what, fd):
1494 logging.warn('Found %s at %s' % (what, fd))
1496 def handleproblem(what, fd, fp):
1498 return ignoreproblem(what, fd, fp)
1500 return removeproblem(what, fd, fp)
1501 logging.error('Found %s at %s' % (what, fd))
1504 # Iterate through all files in the source code
1505 for r, d, f in os.walk(build_dir, topdown=True):
1507 # It's topdown, so checking the basename is enough
1508 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1514 # Path (relative) to the file
1515 fp = os.path.join(r, curfile)
1516 fd = fp[len(build_dir) + 1:]
1519 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1520 except UnicodeError:
1521 warnproblem('malformed magic number', fd)
1523 if mime == 'application/x-sharedlib':
1524 count += handleproblem('shared library', fd, fp)
1526 elif mime == 'application/x-archive':
1527 count += handleproblem('static library', fd, fp)
1529 elif mime == 'application/x-executable':
1530 count += handleproblem('binary executable', fd, fp)
1532 elif mime == 'application/x-java-applet':
1533 count += handleproblem('Java compiled class', fd, fp)
1538 'application/java-archive',
1539 'application/octet-stream',
1542 if has_extension(fp, 'apk'):
1543 removeproblem('APK file', fd, fp)
1545 elif has_extension(fp, 'jar'):
1547 if any(suspect.match(curfile) for suspect in usual_suspects):
1548 count += handleproblem('usual supect', fd, fp)
1550 warnproblem('JAR file', fd)
1552 elif has_extension(fp, 'zip'):
1553 warnproblem('ZIP file', fd)
1556 warnproblem('unknown compressed or binary file', fd)
1558 elif has_extension(fp, 'java'):
1559 for line in file(fp):
1560 if 'DexClassLoader' in line:
1561 count += handleproblem('DexClassLoader', fd, fp)
1566 for p in scanignore:
1567 if p not in scanignore_worked:
1568 logging.error('Unused scanignore path: %s' % p)
1571 for p in scandelete:
1572 if p not in scandelete_worked:
1573 logging.error('Unused scandelete path: %s' % p)
1576 # Presence of a jni directory without buildjni=yes might
1577 # indicate a problem (if it's not a problem, explicitly use
1578 # buildjni=no to bypass this check)
1579 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1580 not thisbuild['buildjni']):
1581 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1590 self.path = os.path.join('stats', 'known_apks.txt')
1592 if os.path.exists(self.path):
1593 for line in file(self.path):
1594 t = line.rstrip().split(' ')
1596 self.apks[t[0]] = (t[1], None)
1598 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1599 self.changed = False
1601 def writeifchanged(self):
1603 if not os.path.exists('stats'):
1605 f = open(self.path, 'w')
1607 for apk, app in self.apks.iteritems():
1609 line = apk + ' ' + appid
1611 line += ' ' + time.strftime('%Y-%m-%d', added)
1613 for line in sorted(lst):
1614 f.write(line + '\n')
1617 # Record an apk (if it's new, otherwise does nothing)
1618 # Returns the date it was added.
1619 def recordapk(self, apk, app):
1620 if apk not in self.apks:
1621 self.apks[apk] = (app, time.gmtime(time.time()))
1623 _, added = self.apks[apk]
1626 # Look up information - given the 'apkname', returns (app id, date added/None).
1627 # Or returns None for an unknown apk.
1628 def getapp(self, apkname):
1629 if apkname in self.apks:
1630 return self.apks[apkname]
1633 # Get the most recent 'num' apps added to the repo, as a list of package ids
1634 # with the most recent first.
1635 def getlatest(self, num):
1637 for apk, app in self.apks.iteritems():
1641 if apps[appid] > added:
1645 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1646 lst = [app for app, _ in sortedapps]
1651 def isApkDebuggable(apkfile, config):
1652 """Returns True if the given apk file is debuggable
1654 :param apkfile: full path to the apk to check"""
1656 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1658 if p.returncode != 0:
1659 logging.critical("Failed to get apk manifest information")
1661 for line in p.output.splitlines():
1662 if 'android:debuggable' in line and not line.endswith('0x0'):
1667 class AsynchronousFileReader(threading.Thread):
1670 Helper class to implement asynchronous reading of a file
1671 in a separate thread. Pushes read lines on a queue to
1672 be consumed in another thread.
1675 def __init__(self, fd, queue):
1676 assert isinstance(queue, Queue.Queue)
1677 assert callable(fd.readline)
1678 threading.Thread.__init__(self)
1683 '''The body of the tread: read lines and put them on the queue.'''
1684 for line in iter(self._fd.readline, ''):
1685 self._queue.put(line)
1688 '''Check whether there is no more content to expect.'''
1689 return not self.is_alive() and self._queue.empty()
1697 def SdkToolsPopen(commands, cwd=None, shell=False, output=True):
1699 if cmd not in config:
1700 config[cmd] = find_sdk_tools_cmd(commands[0])
1701 return FDroidPopen([config[cmd]] + commands[1:],
1702 cwd=cwd, shell=shell, output=output)
1705 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1707 Run a command and capture the possibly huge output.
1709 :param commands: command and argument list like in subprocess.Popen
1710 :param cwd: optionally specifies a working directory
1711 :returns: A PopenResult.
1717 cwd = os.path.normpath(cwd)
1718 logging.debug("Directory: %s" % cwd)
1719 logging.debug("> %s" % ' '.join(commands))
1721 result = PopenResult()
1724 p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1725 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1727 raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
1729 stdout_queue = Queue.Queue()
1730 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1731 stdout_reader.start()
1733 # Check the queue for output (until there is no more to get)
1734 while not stdout_reader.eof():
1735 while not stdout_queue.empty():
1736 line = stdout_queue.get()
1737 if output and options.verbose:
1738 # Output directly to console
1739 sys.stderr.write(line)
1741 result.output += line
1745 result.returncode = p.wait()
1749 def remove_signing_keys(build_dir):
1750 comment = re.compile(r'[ ]*//')
1751 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1753 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1754 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1755 re.compile(r'.*variant\.outputFile = .*'),
1756 re.compile(r'.*output\.outputFile = .*'),
1757 re.compile(r'.*\.readLine\(.*'),
1759 for root, dirs, files in os.walk(build_dir):
1760 if 'build.gradle' in files:
1761 path = os.path.join(root, 'build.gradle')
1763 with open(path, "r") as o:
1764 lines = o.readlines()
1770 with open(path, "w") as o:
1771 while i < len(lines):
1774 while line.endswith('\\\n'):
1775 line = line.rstrip('\\\n') + lines[i]
1778 if comment.match(line):
1782 opened += line.count('{')
1783 opened -= line.count('}')
1786 if signing_configs.match(line):
1791 if any(s.match(line) for s in line_matches):
1799 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1802 'project.properties',
1804 'default.properties',
1805 'ant.properties', ]:
1806 if propfile in files:
1807 path = os.path.join(root, propfile)
1809 with open(path, "r") as o:
1810 lines = o.readlines()
1814 with open(path, "w") as o:
1816 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1823 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1826 def reset_env_path():
1827 global env, orig_path
1828 env['PATH'] = orig_path
1831 def add_to_env_path(path):
1833 paths = env['PATH'].split(os.pathsep)
1837 env['PATH'] = os.pathsep.join(paths)
1840 def replace_config_vars(cmd):
1842 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1843 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1844 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1845 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1849 def place_srclib(root_dir, number, libpath):
1852 relpath = os.path.relpath(libpath, root_dir)
1853 proppath = os.path.join(root_dir, 'project.properties')
1856 if os.path.isfile(proppath):
1857 with open(proppath, "r") as o:
1858 lines = o.readlines()
1860 with open(proppath, "w") as o:
1863 if line.startswith('android.library.reference.%d=' % number):
1864 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1869 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1872 def compare_apks(apk1, apk2, tmp_dir):
1875 Returns None if the apk content is the same (apart from the signing key),
1876 otherwise a string describing what's different, or what went wrong when
1877 trying to do the comparison.
1880 badchars = re.compile('''[/ :;'"]''')
1881 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1882 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1883 for d in [apk1dir, apk2dir]:
1884 if os.path.exists(d):
1887 os.mkdir(os.path.join(d, 'jar-xf'))
1889 if subprocess.call(['jar', 'xf',
1890 os.path.abspath(apk1)],
1891 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1892 return("Failed to unpack " + apk1)
1893 if subprocess.call(['jar', 'xf',
1894 os.path.abspath(apk2)],
1895 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1896 return("Failed to unpack " + apk2)
1898 # try to find apktool in the path, if it hasn't been manually configed
1899 if 'apktool' not in config:
1900 tmp = find_command('apktool')
1902 config['apktool'] = tmp
1903 if 'apktool' in config:
1904 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1906 return("Failed to unpack " + apk1)
1907 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1909 return("Failed to unpack " + apk2)
1911 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1912 lines = p.output.splitlines()
1913 if len(lines) != 1 or 'META-INF' not in lines[0]:
1914 meld = find_command('meld')
1915 if meld is not None:
1916 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1917 return("Unexpected diff output - " + p.output)
1919 # since everything verifies, delete the comparison to keep cruft down
1920 shutil.rmtree(apk1dir)
1921 shutil.rmtree(apk2dir)
1923 # If we get here, it seems like they're the same!
1927 def find_command(command):
1928 '''find the full path of a command, or None if it can't be found in the PATH'''
1931 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1933 fpath, fname = os.path.split(command)
1938 for path in os.environ["PATH"].split(os.pathsep):
1939 path = path.strip('"')
1940 exe_file = os.path.join(path, command)
1941 if is_exe(exe_file):