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 self.username, remote = remote.split('@')
431 if ':' not in self.username:
432 raise VCSException("Password required with username")
433 self.username, self.password = self.username.split(':')
437 self.clone_failed = False
438 self.refreshed = False
444 # Take the local repository to a clean version of the given revision, which
445 # is specificed in the VCS's native format. Beforehand, the repository can
446 # be dirty, or even non-existent. If the repository does already exist
447 # locally, it will be updated from the origin, but only once in the
448 # lifetime of the vcs object.
449 # None is acceptable for 'rev' if you know you are cloning a clean copy of
450 # the repo - otherwise it must specify a valid revision.
451 def gotorevision(self, rev):
453 if self.clone_failed:
454 raise VCSException("Downloading the repository already failed once, not trying again.")
456 # The .fdroidvcs-id file for a repo tells us what VCS type
457 # and remote that directory was created from, allowing us to drop it
458 # automatically if either of those things changes.
459 fdpath = os.path.join(self.local, '..',
460 '.fdroidvcs-' + os.path.basename(self.local))
461 cdata = self.repotype() + ' ' + self.remote
464 if os.path.exists(self.local):
465 if os.path.exists(fdpath):
466 with open(fdpath, 'r') as f:
467 fsdata = f.read().strip()
472 logging.info("Repository details for %s changed - deleting" % (
476 logging.info("Repository details for %s missing - deleting" % (
479 shutil.rmtree(self.local)
484 self.gotorevisionx(rev)
485 except FDroidException, e:
488 # If necessary, write the .fdroidvcs file.
489 if writeback and not self.clone_failed:
490 with open(fdpath, 'w') as f:
496 # Derived classes need to implement this. It's called once basic checking
497 # has been performend.
498 def gotorevisionx(self, rev):
499 raise VCSException("This VCS type doesn't define gotorevisionx")
501 # Initialise and update submodules
502 def initsubmodules(self):
503 raise VCSException('Submodules not supported for this vcs type')
505 # Get a list of all known tags
507 if not self._gettags:
508 raise VCSException('gettags not supported for this vcs type')
510 for tag in self._gettags():
511 if re.match('[-A-Za-z0-9_. ]+$', tag):
515 # Get a list of latest number tags
516 def latesttags(self, number):
517 raise VCSException('latesttags not supported for this vcs type')
519 # Get current commit reference (hash, revision, etc)
521 raise VCSException('getref not supported for this vcs type')
523 # Returns the srclib (name, path) used in setting up the current
534 # If the local directory exists, but is somehow not a git repository, git
535 # will traverse up the directory tree until it finds one that is (i.e.
536 # fdroidserver) and then we'll proceed to destroy it! This is called as
539 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
540 result = p.output.rstrip()
541 if not result.endswith(self.local):
542 raise VCSException('Repository mismatch')
544 def gotorevisionx(self, rev):
545 if not os.path.exists(self.local):
547 p = FDroidPopen(['git', 'clone', self.remote, self.local])
548 if p.returncode != 0:
549 self.clone_failed = True
550 raise VCSException("Git clone failed", p.output)
554 # Discard any working tree changes
555 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
556 if p.returncode != 0:
557 raise VCSException("Git reset failed", p.output)
558 # Remove untracked files now, in case they're tracked in the target
559 # revision (it happens!)
560 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
561 if p.returncode != 0:
562 raise VCSException("Git clean failed", p.output)
563 if not self.refreshed:
564 # Get latest commits and tags from remote
565 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
566 if p.returncode != 0:
567 raise VCSException("Git fetch failed", p.output)
568 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
569 if p.returncode != 0:
570 raise VCSException("Git fetch failed", p.output)
571 # Recreate origin/HEAD as git clone would do it, in case it disappeared
572 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
573 if p.returncode != 0:
574 lines = p.output.splitlines()
575 if 'Multiple remote HEAD branches' not in lines[0]:
576 raise VCSException("Git remote set-head failed", p.output)
577 branch = lines[1].split(' ')[-1]
578 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
579 if p2.returncode != 0:
580 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
581 self.refreshed = True
582 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
583 # a github repo. Most of the time this is the same as origin/master.
584 rev = rev or 'origin/HEAD'
585 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
586 if p.returncode != 0:
587 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
588 # Get rid of any uncontrolled files left behind
589 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
590 if p.returncode != 0:
591 raise VCSException("Git clean failed", p.output)
593 def initsubmodules(self):
595 submfile = os.path.join(self.local, '.gitmodules')
596 if not os.path.isfile(submfile):
597 raise VCSException("No git submodules available")
599 # fix submodules not accessible without an account and public key auth
600 with open(submfile, 'r') as f:
601 lines = f.readlines()
602 with open(submfile, 'w') as f:
604 if 'git@github.com' in line:
605 line = line.replace('git@github.com:', 'https://github.com/')
609 ['git', 'reset', '--hard'],
610 ['git', 'clean', '-dffx'],
612 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
613 if p.returncode != 0:
614 raise VCSException("Git submodule reset failed", p.output)
615 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
616 if p.returncode != 0:
617 raise VCSException("Git submodule sync failed", p.output)
618 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
619 if p.returncode != 0:
620 raise VCSException("Git submodule update failed", p.output)
624 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
625 return p.output.splitlines()
627 def latesttags(self, alltags, number):
629 p = FDroidPopen(['echo "' + '\n'.join(alltags) + '" | '
631 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
632 + 'sort -n | awk \'{print $2}\''],
633 cwd=self.local, shell=True, output=False)
634 return p.output.splitlines()[-number:]
637 class vcs_gitsvn(vcs):
642 # Damn git-svn tries to use a graphical password prompt, so we have to
643 # trick it into taking the password from stdin
645 if self.username is None:
647 return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
649 # If the local directory exists, but is somehow not a git repository, git
650 # will traverse up the directory tree until it finds one that is (i.e.
651 # fdroidserver) and then we'll proceed to destory it! This is called as
654 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
655 result = p.output.rstrip()
656 if not result.endswith(self.local):
657 raise VCSException('Repository mismatch')
659 def gotorevisionx(self, rev):
660 if not os.path.exists(self.local):
662 gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
663 if ';' in self.remote:
664 remote_split = self.remote.split(';')
665 for i in remote_split[1:]:
666 if i.startswith('trunk='):
667 gitsvn_cmd += ' -T %s' % i[6:]
668 elif i.startswith('tags='):
669 gitsvn_cmd += ' -t %s' % i[5:]
670 elif i.startswith('branches='):
671 gitsvn_cmd += ' -b %s' % i[9:]
672 p = FDroidPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True, output=False)
673 if p.returncode != 0:
674 self.clone_failed = True
675 raise VCSException("Git svn clone failed", p.output)
677 p = FDroidPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True, output=False)
678 if p.returncode != 0:
679 self.clone_failed = True
680 raise VCSException("Git svn clone failed", p.output)
684 # Discard any working tree changes
685 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
686 if p.returncode != 0:
687 raise VCSException("Git reset failed", p.output)
688 # Remove untracked files now, in case they're tracked in the target
689 # revision (it happens!)
690 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
691 if p.returncode != 0:
692 raise VCSException("Git clean failed", p.output)
693 if not self.refreshed:
694 # Get new commits, branches and tags from repo
695 p = FDroidPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True, output=False)
696 if p.returncode != 0:
697 raise VCSException("Git svn fetch failed")
698 p = FDroidPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True, output=False)
699 if p.returncode != 0:
700 raise VCSException("Git svn rebase failed", p.output)
701 self.refreshed = True
703 rev = rev or 'master'
705 nospaces_rev = rev.replace(' ', '%20')
706 # Try finding a svn tag
707 for treeish in ['origin/', '']:
708 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
709 if p.returncode == 0:
711 if p.returncode != 0:
712 # No tag found, normal svn rev translation
713 # Translate svn rev into git format
714 rev_split = rev.split('/')
717 for treeish in ['origin/', '']:
718 if len(rev_split) > 1:
719 treeish += rev_split[0]
720 svn_rev = rev_split[1]
723 # if no branch is specified, then assume trunk (i.e. 'master' branch):
727 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
729 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
730 git_rev = p.output.rstrip()
732 if p.returncode == 0 and git_rev:
735 if p.returncode != 0 or not git_rev:
736 # Try a plain git checkout as a last resort
737 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
738 if p.returncode != 0:
739 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
741 # Check out the git rev equivalent to the svn rev
742 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
743 if p.returncode != 0:
744 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
746 # Get rid of any uncontrolled files left behind
747 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
748 if p.returncode != 0:
749 raise VCSException("Git clean failed", p.output)
753 for treeish in ['origin/', '']:
754 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
760 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
761 if p.returncode != 0:
763 return p.output.strip()
771 def gotorevisionx(self, rev):
772 if not os.path.exists(self.local):
773 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
774 if p.returncode != 0:
775 self.clone_failed = True
776 raise VCSException("Hg clone failed", p.output)
778 p = FDroidPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True, output=False)
779 if p.returncode != 0:
780 raise VCSException("Hg clean failed", p.output)
781 if not self.refreshed:
782 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
783 if p.returncode != 0:
784 raise VCSException("Hg pull failed", p.output)
785 self.refreshed = True
787 rev = rev or 'default'
790 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
791 if p.returncode != 0:
792 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
793 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
794 # Also delete untracked files, we have to enable purge extension for that:
795 if "'purge' is provided by the following extension" in p.output:
796 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
797 myfile.write("\n[extensions]\nhgext.purge=\n")
798 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
799 if p.returncode != 0:
800 raise VCSException("HG purge failed", p.output)
801 elif p.returncode != 0:
802 raise VCSException("HG purge failed", p.output)
805 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
806 return p.output.splitlines()[1:]
814 def gotorevisionx(self, rev):
815 if not os.path.exists(self.local):
816 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
817 if p.returncode != 0:
818 self.clone_failed = True
819 raise VCSException("Bzr branch failed", p.output)
821 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
822 if p.returncode != 0:
823 raise VCSException("Bzr revert failed", p.output)
824 if not self.refreshed:
825 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
826 if p.returncode != 0:
827 raise VCSException("Bzr update failed", p.output)
828 self.refreshed = True
830 revargs = list(['-r', rev] if rev else [])
831 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
832 if p.returncode != 0:
833 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
836 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
837 return [tag.split(' ')[0].strip() for tag in
838 p.output.splitlines()]
841 def retrieve_string(app_dir, string, xmlfiles=None):
844 os.path.join(app_dir, 'res'),
845 os.path.join(app_dir, 'src', 'main'),
850 for res_dir in res_dirs:
851 for r, d, f in os.walk(res_dir):
852 if os.path.basename(r) == 'values':
853 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
856 if string.startswith('@string/'):
857 string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
858 elif string.startswith('&') and string.endswith(';'):
859 string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
861 if string_search is not None:
862 for xmlfile in xmlfiles:
863 for line in file(xmlfile):
864 matches = string_search(line)
866 return retrieve_string(app_dir, matches.group(1), xmlfiles)
869 return string.replace("\\'", "'")
872 # Return list of existing files that will be used to find the highest vercode
873 def manifest_paths(app_dir, flavours):
875 possible_manifests = \
876 [os.path.join(app_dir, 'AndroidManifest.xml'),
877 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
878 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
879 os.path.join(app_dir, 'build.gradle')]
881 for flavour in flavours:
884 possible_manifests.append(
885 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
887 return [path for path in possible_manifests if os.path.isfile(path)]
890 # Retrieve the package name. Returns the name, or None if not found.
891 def fetch_real_name(app_dir, flavours):
892 app_search = re.compile(r'.*<application.*').search
893 name_search = re.compile(r'.*android:label="([^"]+)".*').search
895 for f in manifest_paths(app_dir, flavours):
896 if not has_extension(f, 'xml'):
898 logging.debug("fetch_real_name: Checking manifest at " + f)
904 matches = name_search(line)
906 stringname = matches.group(1)
907 logging.debug("fetch_real_name: using string " + stringname)
908 result = retrieve_string(app_dir, stringname)
910 result = result.strip()
915 # Retrieve the version name
916 def version_name(original, app_dir, flavours):
917 for f in manifest_paths(app_dir, flavours):
918 if not has_extension(f, 'xml'):
920 string = retrieve_string(app_dir, original)
926 def get_library_references(root_dir):
928 proppath = os.path.join(root_dir, 'project.properties')
929 if not os.path.isfile(proppath):
931 with open(proppath) as f:
932 for line in f.readlines():
933 if not line.startswith('android.library.reference.'):
935 path = line.split('=')[1].strip()
936 relpath = os.path.join(root_dir, path)
937 if not os.path.isdir(relpath):
939 logging.debug("Found subproject at %s" % path)
940 libraries.append(path)
944 def ant_subprojects(root_dir):
945 subprojects = get_library_references(root_dir)
946 for subpath in subprojects:
947 subrelpath = os.path.join(root_dir, subpath)
948 for p in get_library_references(subrelpath):
949 relp = os.path.normpath(os.path.join(subpath, p))
950 if relp not in subprojects:
951 subprojects.insert(0, relp)
955 def remove_debuggable_flags(root_dir):
956 # Remove forced debuggable flags
957 logging.debug("Removing debuggable flags from %s" % root_dir)
958 for root, dirs, files in os.walk(root_dir):
959 if 'AndroidManifest.xml' in files:
960 path = os.path.join(root, 'AndroidManifest.xml')
961 p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
962 if p.returncode != 0:
963 raise BuildException("Failed to remove debuggable flags of %s" % path)
966 # Extract some information from the AndroidManifest.xml at the given path.
967 # Returns (version, vercode, package), any or all of which might be None.
968 # All values returned are strings.
969 def parse_androidmanifests(paths, ignoreversions=None):
972 return (None, None, None)
974 vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
975 vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
976 psearch = re.compile(r'.*package="([^"]+)".*').search
978 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
979 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
980 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
982 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
990 logging.debug("Parsing manifest at {0}".format(path))
991 gradle = has_extension(path, 'gradle')
994 # Remember package name, may be defined separately from version+vercode
995 package = max_package
997 for line in file(path):
1000 matches = psearch_g(line)
1002 matches = psearch(line)
1004 package = matches.group(1)
1007 matches = vnsearch_g(line)
1009 matches = vnsearch(line)
1011 version = matches.group(2 if gradle else 1)
1014 matches = vcsearch_g(line)
1016 matches = vcsearch(line)
1018 vercode = matches.group(1)
1020 logging.debug("..got package={0}, version={1}, vercode={2}"
1021 .format(package, version, vercode))
1023 # Always grab the package name and version name in case they are not
1024 # together with the highest version code
1025 if max_package is None and package is not None:
1026 max_package = package
1027 if max_version is None and version is not None:
1028 max_version = version
1030 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1031 if not ignoresearch or not ignoresearch(version):
1032 if version is not None:
1033 max_version = version
1034 if vercode is not None:
1035 max_vercode = vercode
1036 if package is not None:
1037 max_package = package
1039 max_version = "Ignore"
1041 if max_version is None:
1042 max_version = "Unknown"
1044 return (max_version, max_vercode, max_package)
1047 class FDroidException(Exception):
1049 def __init__(self, value, detail=None):
1051 self.detail = detail
1053 def get_wikitext(self):
1054 ret = repr(self.value) + "\n"
1058 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1066 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1070 class VCSException(FDroidException):
1074 class BuildException(FDroidException):
1078 # Get the specified source library.
1079 # Returns the path to it. Normally this is the path to be used when referencing
1080 # it, which may be a subdirectory of the actual project. If you want the base
1081 # directory of the project, pass 'basepath=True'.
1082 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1083 basepath=False, raw=False, prepare=True, preponly=False):
1091 name, ref = spec.split('@')
1093 number, name = name.split(':', 1)
1095 name, subdir = name.split('/', 1)
1097 if name not in metadata.srclibs:
1098 raise VCSException('srclib ' + name + ' not found.')
1100 srclib = metadata.srclibs[name]
1102 sdir = os.path.join(srclib_dir, name)
1105 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1106 vcs.srclib = (name, number, sdir)
1108 vcs.gotorevision(ref)
1115 libdir = os.path.join(sdir, subdir)
1116 elif srclib["Subdir"]:
1117 for subdir in srclib["Subdir"]:
1118 libdir_candidate = os.path.join(sdir, subdir)
1119 if os.path.exists(libdir_candidate):
1120 libdir = libdir_candidate
1126 if srclib["Srclibs"]:
1128 for lib in srclib["Srclibs"].replace(';', ',').split(','):
1130 for t in srclibpaths:
1135 raise VCSException('Missing recursive srclib %s for %s' % (
1137 place_srclib(libdir, n, s_tuple[2])
1140 remove_signing_keys(sdir)
1141 remove_debuggable_flags(sdir)
1145 if srclib["Prepare"]:
1146 cmd = replace_config_vars(srclib["Prepare"])
1148 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1149 if p.returncode != 0:
1150 raise BuildException("Error running prepare command for srclib %s"
1156 return (name, number, libdir)
1159 # Prepare the source code for a particular build
1160 # 'vcs' - the appropriate vcs object for the application
1161 # 'app' - the application details from the metadata
1162 # 'build' - the build details from the metadata
1163 # 'build_dir' - the path to the build directory, usually
1165 # 'srclib_dir' - the path to the source libraries directory, usually
1167 # 'extlib_dir' - the path to the external libraries directory, usually
1169 # Returns the (root, srclibpaths) where:
1170 # 'root' is the root directory, which may be the same as 'build_dir' or may
1171 # be a subdirectory of it.
1172 # 'srclibpaths' is information on the srclibs being used
1173 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1175 # Optionally, the actual app source can be in a subdirectory
1177 root_dir = os.path.join(build_dir, build['subdir'])
1179 root_dir = build_dir
1181 # Get a working copy of the right revision
1182 logging.info("Getting source for revision " + build['commit'])
1183 vcs.gotorevision(build['commit'])
1185 # Initialise submodules if requred
1186 if build['submodules']:
1187 logging.info("Initialising submodules")
1188 vcs.initsubmodules()
1190 # Check that a subdir (if we're using one) exists. This has to happen
1191 # after the checkout, since it might not exist elsewhere
1192 if not os.path.exists(root_dir):
1193 raise BuildException('Missing subdir ' + root_dir)
1195 # Run an init command if one is required
1197 cmd = replace_config_vars(build['init'])
1198 logging.info("Running 'init' commands in %s" % root_dir)
1200 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1201 if p.returncode != 0:
1202 raise BuildException("Error running init command for %s:%s" %
1203 (app['id'], build['version']), p.output)
1205 # Apply patches if any
1207 logging.info("Applying patches")
1208 for patch in build['patch']:
1209 patch = patch.strip()
1210 logging.info("Applying " + patch)
1211 patch_path = os.path.join('metadata', app['id'], patch)
1212 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1213 if p.returncode != 0:
1214 raise BuildException("Failed to apply patch %s" % patch_path)
1216 # Get required source libraries
1218 if build['srclibs']:
1219 logging.info("Collecting source libraries")
1220 for lib in build['srclibs']:
1221 srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1224 for name, number, libpath in srclibpaths:
1225 place_srclib(root_dir, int(number) if number else None, libpath)
1227 basesrclib = vcs.getsrclib()
1228 # If one was used for the main source, add that too.
1230 srclibpaths.append(basesrclib)
1232 # Update the local.properties file
1233 localprops = [os.path.join(build_dir, 'local.properties')]
1235 localprops += [os.path.join(root_dir, 'local.properties')]
1236 for path in localprops:
1238 if os.path.isfile(path):
1239 logging.info("Updating local.properties file at %s" % path)
1245 logging.info("Creating local.properties file at %s" % path)
1246 # Fix old-fashioned 'sdk-location' by copying
1247 # from sdk.dir, if necessary
1248 if build['oldsdkloc']:
1249 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1250 re.S | re.M).group(1)
1251 props += "sdk-location=%s\n" % sdkloc
1253 props += "sdk.dir=%s\n" % config['sdk_path']
1254 props += "sdk-location=%s\n" % config['sdk_path']
1255 if build['ndk_path']:
1257 props += "ndk.dir=%s\n" % build['ndk_path']
1258 props += "ndk-location=%s\n" % build['ndk_path']
1259 # Add java.encoding if necessary
1260 if build['encoding']:
1261 props += "java.encoding=%s\n" % build['encoding']
1267 if build['type'] == 'gradle':
1268 flavours = build['gradle']
1270 version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1271 gradlepluginver = None
1273 gradle_files = [os.path.join(root_dir, 'build.gradle')]
1275 # Parent dir build.gradle
1276 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1277 if parent_dir.startswith(build_dir):
1278 gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1280 for path in gradle_files:
1283 if not os.path.isfile(path):
1285 with open(path) as f:
1287 match = version_regex.match(line)
1289 gradlepluginver = match.group(1)
1293 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1295 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1296 build['gradlepluginver'] = LooseVersion('0.11')
1299 n = build["target"].split('-')[1]
1300 FDroidPopen(['sed', '-i',
1301 's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1302 'build.gradle'], cwd=root_dir, output=False)
1304 # Remove forced debuggable flags
1305 remove_debuggable_flags(root_dir)
1307 # Insert version code and number into the manifest if necessary
1308 if build['forceversion']:
1309 logging.info("Changing the version name")
1310 for path in manifest_paths(root_dir, flavours):
1311 if not os.path.isfile(path):
1313 if has_extension(path, 'xml'):
1314 p = FDroidPopen(['sed', '-i',
1315 's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1316 path], output=False)
1317 if p.returncode != 0:
1318 raise BuildException("Failed to amend manifest")
1319 elif has_extension(path, 'gradle'):
1320 p = FDroidPopen(['sed', '-i',
1321 's/versionName *=* *"[^"]*"/versionName = "' + build['version'] + '"/g',
1322 path], output=False)
1323 if p.returncode != 0:
1324 raise BuildException("Failed to amend build.gradle")
1325 if build['forcevercode']:
1326 logging.info("Changing the version code")
1327 for path in manifest_paths(root_dir, flavours):
1328 if not os.path.isfile(path):
1330 if has_extension(path, 'xml'):
1331 p = FDroidPopen(['sed', '-i',
1332 's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1333 path], output=False)
1334 if p.returncode != 0:
1335 raise BuildException("Failed to amend manifest")
1336 elif has_extension(path, 'gradle'):
1337 p = FDroidPopen(['sed', '-i',
1338 's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1339 path], output=False)
1340 if p.returncode != 0:
1341 raise BuildException("Failed to amend build.gradle")
1343 # Delete unwanted files
1345 logging.info("Removing specified files")
1346 for part in getpaths(build_dir, build, 'rm'):
1347 dest = os.path.join(build_dir, part)
1348 logging.info("Removing {0}".format(part))
1349 if os.path.lexists(dest):
1350 if os.path.islink(dest):
1351 FDroidPopen(['unlink ' + dest], shell=True, output=False)
1353 FDroidPopen(['rm -rf ' + dest], shell=True, output=False)
1355 logging.info("...but it didn't exist")
1357 remove_signing_keys(build_dir)
1359 # Add required external libraries
1360 if build['extlibs']:
1361 logging.info("Collecting prebuilt libraries")
1362 libsdir = os.path.join(root_dir, 'libs')
1363 if not os.path.exists(libsdir):
1365 for lib in build['extlibs']:
1367 logging.info("...installing extlib {0}".format(lib))
1368 libf = os.path.basename(lib)
1369 libsrc = os.path.join(extlib_dir, lib)
1370 if not os.path.exists(libsrc):
1371 raise BuildException("Missing extlib file {0}".format(libsrc))
1372 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1374 # Run a pre-build command if one is required
1375 if build['prebuild']:
1376 logging.info("Running 'prebuild' commands in %s" % root_dir)
1378 cmd = replace_config_vars(build['prebuild'])
1380 # Substitute source library paths into prebuild commands
1381 for name, number, libpath in srclibpaths:
1382 libpath = os.path.relpath(libpath, root_dir)
1383 cmd = cmd.replace('$$' + name + '$$', libpath)
1385 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1386 if p.returncode != 0:
1387 raise BuildException("Error running prebuild command for %s:%s" %
1388 (app['id'], build['version']), p.output)
1390 # Generate (or update) the ant build file, build.xml...
1391 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1392 parms = ['android', 'update', 'lib-project']
1393 lparms = ['android', 'update', 'project']
1396 parms += ['-t', build['target']]
1397 lparms += ['-t', build['target']]
1398 if build['update'] == ['auto']:
1399 update_dirs = ant_subprojects(root_dir) + ['.']
1401 update_dirs = build['update']
1403 for d in update_dirs:
1404 subdir = os.path.join(root_dir, d)
1406 logging.debug("Updating main project")
1407 cmd = parms + ['-p', d]
1409 logging.debug("Updating subproject %s" % d)
1410 cmd = lparms + ['-p', d]
1411 p = SdkToolsPopen(cmd, cwd=root_dir)
1412 # Check to see whether an error was returned without a proper exit
1413 # code (this is the case for the 'no target set or target invalid'
1415 if p.returncode != 0 or p.output.startswith("Error: "):
1416 raise BuildException("Failed to update project at %s" % d, p.output)
1417 # Clean update dirs via ant
1419 logging.info("Cleaning subproject %s" % d)
1420 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1422 return (root_dir, srclibpaths)
1425 # Split and extend via globbing the paths from a field
1426 def getpaths(build_dir, build, field):
1428 for p in build[field]:
1430 full_path = os.path.join(build_dir, p)
1431 full_path = os.path.normpath(full_path)
1432 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1436 # Scan the source code in the given directory (and all subdirectories)
1437 # and return the number of fatal problems encountered
1438 def scan_source(build_dir, root_dir, thisbuild):
1442 # Common known non-free blobs (always lower case):
1444 re.compile(r'flurryagent', re.IGNORECASE),
1445 re.compile(r'paypal.*mpl', re.IGNORECASE),
1446 re.compile(r'google.*analytics', re.IGNORECASE),
1447 re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1448 re.compile(r'google.*ad.*view', re.IGNORECASE),
1449 re.compile(r'google.*admob', re.IGNORECASE),
1450 re.compile(r'google.*play.*services', re.IGNORECASE),
1451 re.compile(r'crittercism', re.IGNORECASE),
1452 re.compile(r'heyzap', re.IGNORECASE),
1453 re.compile(r'jpct.*ae', re.IGNORECASE),
1454 re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1455 re.compile(r'bugsense', re.IGNORECASE),
1456 re.compile(r'crashlytics', re.IGNORECASE),
1457 re.compile(r'ouya.*sdk', re.IGNORECASE),
1458 re.compile(r'libspen23', re.IGNORECASE),
1461 scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1462 scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1464 scanignore_worked = set()
1465 scandelete_worked = set()
1468 ms = magic.open(magic.MIME_TYPE)
1470 except AttributeError:
1474 for p in scanignore:
1475 if fd.startswith(p):
1476 scanignore_worked.add(p)
1481 for p in scandelete:
1482 if fd.startswith(p):
1483 scandelete_worked.add(p)
1487 def ignoreproblem(what, fd, fp):
1488 logging.info('Ignoring %s at %s' % (what, fd))
1491 def removeproblem(what, fd, fp):
1492 logging.info('Removing %s at %s' % (what, fd))
1496 def warnproblem(what, fd):
1497 logging.warn('Found %s at %s' % (what, fd))
1499 def handleproblem(what, fd, fp):
1501 return ignoreproblem(what, fd, fp)
1503 return removeproblem(what, fd, fp)
1504 logging.error('Found %s at %s' % (what, fd))
1507 # Iterate through all files in the source code
1508 for r, d, f in os.walk(build_dir, topdown=True):
1510 # It's topdown, so checking the basename is enough
1511 for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1517 # Path (relative) to the file
1518 fp = os.path.join(r, curfile)
1519 fd = fp[len(build_dir) + 1:]
1522 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1523 except UnicodeError:
1524 warnproblem('malformed magic number', fd)
1526 if mime == 'application/x-sharedlib':
1527 count += handleproblem('shared library', fd, fp)
1529 elif mime == 'application/x-archive':
1530 count += handleproblem('static library', fd, fp)
1532 elif mime == 'application/x-executable':
1533 count += handleproblem('binary executable', fd, fp)
1535 elif mime == 'application/x-java-applet':
1536 count += handleproblem('Java compiled class', fd, fp)
1541 'application/java-archive',
1542 'application/octet-stream',
1546 if has_extension(fp, 'apk'):
1547 removeproblem('APK file', fd, fp)
1549 elif has_extension(fp, 'jar'):
1551 if any(suspect.match(curfile) for suspect in usual_suspects):
1552 count += handleproblem('usual supect', fd, fp)
1554 warnproblem('JAR file', fd)
1556 elif has_extension(fp, 'zip'):
1557 warnproblem('ZIP file', fd)
1560 warnproblem('unknown compressed or binary file', fd)
1562 elif has_extension(fp, 'java'):
1563 for line in file(fp):
1564 if 'DexClassLoader' in line:
1565 count += handleproblem('DexClassLoader', fd, fp)
1570 for p in scanignore:
1571 if p not in scanignore_worked:
1572 logging.error('Unused scanignore path: %s' % p)
1575 for p in scandelete:
1576 if p not in scandelete_worked:
1577 logging.error('Unused scandelete path: %s' % p)
1580 # Presence of a jni directory without buildjni=yes might
1581 # indicate a problem (if it's not a problem, explicitly use
1582 # buildjni=no to bypass this check)
1583 if (os.path.exists(os.path.join(root_dir, 'jni')) and
1584 not thisbuild['buildjni']):
1585 logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1594 self.path = os.path.join('stats', 'known_apks.txt')
1596 if os.path.exists(self.path):
1597 for line in file(self.path):
1598 t = line.rstrip().split(' ')
1600 self.apks[t[0]] = (t[1], None)
1602 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1603 self.changed = False
1605 def writeifchanged(self):
1607 if not os.path.exists('stats'):
1609 f = open(self.path, 'w')
1611 for apk, app in self.apks.iteritems():
1613 line = apk + ' ' + appid
1615 line += ' ' + time.strftime('%Y-%m-%d', added)
1617 for line in sorted(lst):
1618 f.write(line + '\n')
1621 # Record an apk (if it's new, otherwise does nothing)
1622 # Returns the date it was added.
1623 def recordapk(self, apk, app):
1624 if apk not in self.apks:
1625 self.apks[apk] = (app, time.gmtime(time.time()))
1627 _, added = self.apks[apk]
1630 # Look up information - given the 'apkname', returns (app id, date added/None).
1631 # Or returns None for an unknown apk.
1632 def getapp(self, apkname):
1633 if apkname in self.apks:
1634 return self.apks[apkname]
1637 # Get the most recent 'num' apps added to the repo, as a list of package ids
1638 # with the most recent first.
1639 def getlatest(self, num):
1641 for apk, app in self.apks.iteritems():
1645 if apps[appid] > added:
1649 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1650 lst = [app for app, _ in sortedapps]
1655 def isApkDebuggable(apkfile, config):
1656 """Returns True if the given apk file is debuggable
1658 :param apkfile: full path to the apk to check"""
1660 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1662 if p.returncode != 0:
1663 logging.critical("Failed to get apk manifest information")
1665 for line in p.output.splitlines():
1666 if 'android:debuggable' in line and not line.endswith('0x0'):
1671 class AsynchronousFileReader(threading.Thread):
1674 Helper class to implement asynchronous reading of a file
1675 in a separate thread. Pushes read lines on a queue to
1676 be consumed in another thread.
1679 def __init__(self, fd, queue):
1680 assert isinstance(queue, Queue.Queue)
1681 assert callable(fd.readline)
1682 threading.Thread.__init__(self)
1687 '''The body of the tread: read lines and put them on the queue.'''
1688 for line in iter(self._fd.readline, ''):
1689 self._queue.put(line)
1692 '''Check whether there is no more content to expect.'''
1693 return not self.is_alive() and self._queue.empty()
1701 def SdkToolsPopen(commands, cwd=None, shell=False, output=True):
1703 if cmd not in config:
1704 config[cmd] = find_sdk_tools_cmd(commands[0])
1705 return FDroidPopen([config[cmd]] + commands[1:],
1706 cwd=cwd, shell=shell, output=output)
1709 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1711 Run a command and capture the possibly huge output.
1713 :param commands: command and argument list like in subprocess.Popen
1714 :param cwd: optionally specifies a working directory
1715 :returns: A PopenResult.
1721 cwd = os.path.normpath(cwd)
1722 logging.debug("Directory: %s" % cwd)
1723 logging.debug("> %s" % ' '.join(commands))
1725 result = PopenResult()
1728 p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1729 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1731 raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
1733 stdout_queue = Queue.Queue()
1734 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1735 stdout_reader.start()
1737 # Check the queue for output (until there is no more to get)
1738 while not stdout_reader.eof():
1739 while not stdout_queue.empty():
1740 line = stdout_queue.get()
1741 if output and options.verbose:
1742 # Output directly to console
1743 sys.stderr.write(line)
1745 result.output += line
1749 result.returncode = p.wait()
1753 def remove_signing_keys(build_dir):
1754 comment = re.compile(r'[ ]*//')
1755 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1757 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1758 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1759 re.compile(r'.*variant\.outputFile = .*'),
1760 re.compile(r'.*output\.outputFile = .*'),
1761 re.compile(r'.*\.readLine\(.*'),
1763 for root, dirs, files in os.walk(build_dir):
1764 if 'build.gradle' in files:
1765 path = os.path.join(root, 'build.gradle')
1767 with open(path, "r") as o:
1768 lines = o.readlines()
1774 with open(path, "w") as o:
1775 while i < len(lines):
1778 while line.endswith('\\\n'):
1779 line = line.rstrip('\\\n') + lines[i]
1782 if comment.match(line):
1786 opened += line.count('{')
1787 opened -= line.count('}')
1790 if signing_configs.match(line):
1795 if any(s.match(line) for s in line_matches):
1803 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1806 'project.properties',
1808 'default.properties',
1811 if propfile in files:
1812 path = os.path.join(root, propfile)
1814 with open(path, "r") as o:
1815 lines = o.readlines()
1819 with open(path, "w") as o:
1821 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1828 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1831 def reset_env_path():
1832 global env, orig_path
1833 env['PATH'] = orig_path
1836 def add_to_env_path(path):
1838 paths = env['PATH'].split(os.pathsep)
1842 env['PATH'] = os.pathsep.join(paths)
1845 def replace_config_vars(cmd):
1847 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1848 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1849 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1850 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1854 def place_srclib(root_dir, number, libpath):
1857 relpath = os.path.relpath(libpath, root_dir)
1858 proppath = os.path.join(root_dir, 'project.properties')
1861 if os.path.isfile(proppath):
1862 with open(proppath, "r") as o:
1863 lines = o.readlines()
1865 with open(proppath, "w") as o:
1868 if line.startswith('android.library.reference.%d=' % number):
1869 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1874 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1877 def compare_apks(apk1, apk2, tmp_dir):
1880 Returns None if the apk content is the same (apart from the signing key),
1881 otherwise a string describing what's different, or what went wrong when
1882 trying to do the comparison.
1885 thisdir = os.path.join(tmp_dir, 'this_apk')
1886 thatdir = os.path.join(tmp_dir, 'that_apk')
1887 for d in [thisdir, thatdir]:
1888 if os.path.exists(d):
1892 if subprocess.call(['jar', 'xf',
1893 os.path.abspath(apk1)],
1895 return("Failed to unpack " + apk1)
1896 if subprocess.call(['jar', 'xf',
1897 os.path.abspath(apk2)],
1899 return("Failed to unpack " + apk2)
1901 p = FDroidPopen(['diff', '-r', 'this_apk', 'that_apk'], cwd=tmp_dir,
1903 lines = p.output.splitlines()
1904 if len(lines) != 1 or 'META-INF' not in lines[0]:
1905 return("Unexpected diff output - " + p.output)
1907 # If we get here, it seems like they're the same!