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/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
36 import xml.etree.ElementTree as XMLElementTree
38 from distutils.version import LooseVersion
39 from zipfile import ZipFile
42 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
45 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
54 'sdk_path': "$ANDROID_HOME",
57 'r10e': "$ANDROID_NDK",
59 'build_tools': "23.0.1",
63 'accepted_formats': ['txt', 'yaml'],
64 'sync_from_local_copy_dir': False,
65 'per_app_repos': False,
66 'make_current_version_link': True,
67 'current_version_name_source': 'Name',
68 'update_stats': False,
72 'stats_to_carbon': False,
74 'build_server_always': False,
75 'keystore': 'keystore.jks',
76 'smartcardoptions': [],
82 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
83 'repo_name': "My First FDroid Repo Demo",
84 'repo_icon': "fdroid-icon.png",
85 'repo_description': '''
86 This is a repository of apps to be used with FDroid. Applications in this
87 repository are either official binaries built by the original application
88 developers, or are binaries built from source by the admin of f-droid.org
89 using the tools on https://gitlab.com/u/fdroid.
95 def setup_global_opts(parser):
96 parser.add_argument("-v", "--verbose", action="store_true", default=False,
97 help="Spew out even more information than normal")
98 parser.add_argument("-q", "--quiet", action="store_true", default=False,
99 help="Restrict output to warnings and errors")
102 def fill_config_defaults(thisconfig):
103 for k, v in default_config.items():
104 if k not in thisconfig:
107 # Expand paths (~users and $vars)
108 def expand_path(path):
112 path = os.path.expanduser(path)
113 path = os.path.expandvars(path)
118 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
123 thisconfig[k + '_orig'] = v
125 for k in ['ndk_paths']:
131 thisconfig[k][k2] = exp
132 thisconfig[k][k2 + '_orig'] = v
135 def regsub_file(pattern, repl, path):
136 with open(path, 'r') as f:
138 text = re.sub(pattern, repl, text)
139 with open(path, 'w') as f:
143 def read_config(opts, config_file='config.py'):
144 """Read the repository config
146 The config is read from config_file, which is in the current directory when
147 any of the repo management commands are used.
149 global config, options, env, orig_path
151 if config is not None:
153 if not os.path.isfile(config_file):
154 logging.critical("Missing config file - is this a repo directory?")
161 logging.debug("Reading %s" % config_file)
162 execfile(config_file, config)
164 # smartcardoptions must be a list since its command line args for Popen
165 if 'smartcardoptions' in config:
166 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
167 elif 'keystore' in config and config['keystore'] == 'NONE':
168 # keystore='NONE' means use smartcard, these are required defaults
169 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
170 'SunPKCS11-OpenSC', '-providerClass',
171 'sun.security.pkcs11.SunPKCS11',
172 '-providerArg', 'opensc-fdroid.cfg']
174 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
175 st = os.stat(config_file)
176 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
177 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
179 fill_config_defaults(config)
181 # There is no standard, so just set up the most common environment
184 orig_path = env['PATH']
185 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
186 env[n] = config['sdk_path']
188 for k in ["keystorepass", "keypass"]:
190 write_password_file(k)
192 for k in ["repo_description", "archive_description"]:
194 config[k] = clean_description(config[k])
196 if 'serverwebroot' in config:
197 if isinstance(config['serverwebroot'], basestring):
198 roots = [config['serverwebroot']]
199 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
200 roots = config['serverwebroot']
202 raise TypeError('only accepts strings, lists, and tuples')
204 for rootstr in roots:
205 # since this is used with rsync, where trailing slashes have
206 # meaning, ensure there is always a trailing slash
207 if rootstr[-1] != '/':
209 rootlist.append(rootstr.replace('//', '/'))
210 config['serverwebroot'] = rootlist
215 def get_ndk_path(version):
217 version = 'r10e' # falls back to latest
218 paths = config['ndk_paths']
219 if version not in paths:
221 return paths[version] or ''
224 def find_sdk_tools_cmd(cmd):
225 '''find a working path to a tool from the Android SDK'''
228 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
229 # try to find a working path to this command, in all the recent possible paths
230 if 'build_tools' in config:
231 build_tools = os.path.join(config['sdk_path'], 'build-tools')
232 # if 'build_tools' was manually set and exists, check only that one
233 configed_build_tools = os.path.join(build_tools, config['build_tools'])
234 if os.path.exists(configed_build_tools):
235 tooldirs.append(configed_build_tools)
237 # no configed version, so hunt known paths for it
238 for f in sorted(os.listdir(build_tools), reverse=True):
239 if os.path.isdir(os.path.join(build_tools, f)):
240 tooldirs.append(os.path.join(build_tools, f))
241 tooldirs.append(build_tools)
242 sdk_tools = os.path.join(config['sdk_path'], 'tools')
243 if os.path.exists(sdk_tools):
244 tooldirs.append(sdk_tools)
245 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
246 if os.path.exists(sdk_platform_tools):
247 tooldirs.append(sdk_platform_tools)
248 tooldirs.append('/usr/bin')
250 if os.path.isfile(os.path.join(d, cmd)):
251 return os.path.join(d, cmd)
252 # did not find the command, exit with error message
253 ensure_build_tools_exists(config)
256 def test_sdk_exists(thisconfig):
257 if 'sdk_path' not in thisconfig:
258 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
261 logging.error("'sdk_path' not set in config.py!")
263 if thisconfig['sdk_path'] == default_config['sdk_path']:
264 logging.error('No Android SDK found!')
265 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
266 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
268 if not os.path.exists(thisconfig['sdk_path']):
269 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
271 if not os.path.isdir(thisconfig['sdk_path']):
272 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
274 for d in ['build-tools', 'platform-tools', 'tools']:
275 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
276 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
277 thisconfig['sdk_path'], d))
282 def ensure_build_tools_exists(thisconfig):
283 if not test_sdk_exists(thisconfig):
285 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
286 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
287 if not os.path.isdir(versioned_build_tools):
288 logging.critical('Android Build Tools path "'
289 + versioned_build_tools + '" does not exist!')
293 def write_password_file(pwtype, password=None):
295 writes out passwords to a protected file instead of passing passwords as
296 command line argments
298 filename = '.fdroid.' + pwtype + '.txt'
299 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
301 os.write(fd, config[pwtype])
303 os.write(fd, password)
305 config[pwtype + 'file'] = filename
308 # Given the arguments in the form of multiple appid:[vc] strings, this returns
309 # a dictionary with the set of vercodes specified for each package.
310 def read_pkg_args(args, allow_vercodes=False):
317 if allow_vercodes and ':' in p:
318 package, vercode = p.split(':')
320 package, vercode = p, None
321 if package not in vercodes:
322 vercodes[package] = [vercode] if vercode else []
324 elif vercode and vercode not in vercodes[package]:
325 vercodes[package] += [vercode] if vercode else []
330 # On top of what read_pkg_args does, this returns the whole app metadata, but
331 # limiting the builds list to the builds matching the vercodes specified.
332 def read_app_args(args, allapps, allow_vercodes=False):
334 vercodes = read_pkg_args(args, allow_vercodes)
340 for appid, app in allapps.iteritems():
341 if appid in vercodes:
344 if len(apps) != len(vercodes):
347 logging.critical("No such package: %s" % p)
348 raise FDroidException("Found invalid app ids in arguments")
350 raise FDroidException("No packages specified")
353 for appid, app in apps.iteritems():
357 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
358 if len(app['builds']) != len(vercodes[appid]):
360 allvcs = [b['vercode'] for b in app['builds']]
361 for v in vercodes[appid]:
363 logging.critical("No such vercode %s for app %s" % (v, appid))
366 raise FDroidException("Found invalid vercodes for some apps")
371 def get_extension(filename):
372 base, ext = os.path.splitext(filename)
375 return base, ext.lower()[1:]
378 def has_extension(filename, ext):
379 _, f_ext = get_extension(filename)
386 def clean_description(description):
387 'Remove unneeded newlines and spaces from a block of description text'
389 # this is split up by paragraph to make removing the newlines easier
390 for paragraph in re.split(r'\n\n', description):
391 paragraph = re.sub('\r', '', paragraph)
392 paragraph = re.sub('\n', ' ', paragraph)
393 paragraph = re.sub(' {2,}', ' ', paragraph)
394 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
395 returnstring += paragraph + '\n\n'
396 return returnstring.rstrip('\n')
399 def apknameinfo(filename):
401 filename = os.path.basename(filename)
402 if apk_regex is None:
403 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
404 m = apk_regex.match(filename)
406 result = (m.group(1), m.group(2))
407 except AttributeError:
408 raise FDroidException("Invalid apk name: %s" % filename)
412 def getapkname(app, build):
413 return "%s_%s.apk" % (app['id'], build['vercode'])
416 def getsrcname(app, build):
417 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
424 return app['Auto Name']
429 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
432 def getvcs(vcstype, remote, local):
434 return vcs_git(remote, local)
435 if vcstype == 'git-svn':
436 return vcs_gitsvn(remote, local)
438 return vcs_hg(remote, local)
440 return vcs_bzr(remote, local)
441 if vcstype == 'srclib':
442 if local != os.path.join('build', 'srclib', remote):
443 raise VCSException("Error: srclib paths are hard-coded!")
444 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
446 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
447 raise VCSException("Invalid vcs type " + vcstype)
450 def getsrclibvcs(name):
451 if name not in metadata.srclibs:
452 raise VCSException("Missing srclib " + name)
453 return metadata.srclibs[name]['Repo Type']
458 def __init__(self, remote, local):
460 # svn, git-svn and bzr may require auth
462 if self.repotype() in ('git-svn', 'bzr'):
464 if self.repotype == 'git-svn':
465 raise VCSException("Authentication is not supported for git-svn")
466 self.username, remote = remote.split('@')
467 if ':' not in self.username:
468 raise VCSException("Password required with username")
469 self.username, self.password = self.username.split(':')
473 self.clone_failed = False
474 self.refreshed = False
480 # Take the local repository to a clean version of the given revision, which
481 # is specificed in the VCS's native format. Beforehand, the repository can
482 # be dirty, or even non-existent. If the repository does already exist
483 # locally, it will be updated from the origin, but only once in the
484 # lifetime of the vcs object.
485 # None is acceptable for 'rev' if you know you are cloning a clean copy of
486 # the repo - otherwise it must specify a valid revision.
487 def gotorevision(self, rev, refresh=True):
489 if self.clone_failed:
490 raise VCSException("Downloading the repository already failed once, not trying again.")
492 # The .fdroidvcs-id file for a repo tells us what VCS type
493 # and remote that directory was created from, allowing us to drop it
494 # automatically if either of those things changes.
495 fdpath = os.path.join(self.local, '..',
496 '.fdroidvcs-' + os.path.basename(self.local))
497 cdata = self.repotype() + ' ' + self.remote
500 if os.path.exists(self.local):
501 if os.path.exists(fdpath):
502 with open(fdpath, 'r') as f:
503 fsdata = f.read().strip()
508 logging.info("Repository details for %s changed - deleting" % (
512 logging.info("Repository details for %s missing - deleting" % (
515 shutil.rmtree(self.local)
519 self.refreshed = True
522 self.gotorevisionx(rev)
523 except FDroidException, e:
526 # If necessary, write the .fdroidvcs file.
527 if writeback and not self.clone_failed:
528 with open(fdpath, 'w') as f:
534 # Derived classes need to implement this. It's called once basic checking
535 # has been performend.
536 def gotorevisionx(self, rev):
537 raise VCSException("This VCS type doesn't define gotorevisionx")
539 # Initialise and update submodules
540 def initsubmodules(self):
541 raise VCSException('Submodules not supported for this vcs type')
543 # Get a list of all known tags
545 if not self._gettags:
546 raise VCSException('gettags not supported for this vcs type')
548 for tag in self._gettags():
549 if re.match('[-A-Za-z0-9_. /]+$', tag):
553 def latesttags(self, tags, number):
554 """Get the most recent tags in a given list.
556 :param tags: a list of tags
557 :param number: the number to return
558 :returns: A list containing the most recent tags in the provided
559 list, up to the maximum number given.
561 raise VCSException('latesttags not supported for this vcs type')
563 # Get current commit reference (hash, revision, etc)
565 raise VCSException('getref not supported for this vcs type')
567 # Returns the srclib (name, path) used in setting up the current
578 # If the local directory exists, but is somehow not a git repository, git
579 # will traverse up the directory tree until it finds one that is (i.e.
580 # fdroidserver) and then we'll proceed to destroy it! This is called as
583 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
584 result = p.output.rstrip()
585 if not result.endswith(self.local):
586 raise VCSException('Repository mismatch')
588 def gotorevisionx(self, rev):
589 if not os.path.exists(self.local):
591 p = FDroidPopen(['git', 'clone', self.remote, self.local])
592 if p.returncode != 0:
593 self.clone_failed = True
594 raise VCSException("Git clone failed", p.output)
598 # Discard any working tree changes
599 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
600 'git', 'reset', '--hard'], cwd=self.local, output=False)
601 if p.returncode != 0:
602 raise VCSException("Git reset failed", p.output)
603 # Remove untracked files now, in case they're tracked in the target
604 # revision (it happens!)
605 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
606 'git', 'clean', '-dffx'], cwd=self.local, output=False)
607 if p.returncode != 0:
608 raise VCSException("Git clean failed", p.output)
609 if not self.refreshed:
610 # Get latest commits and tags from remote
611 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
612 if p.returncode != 0:
613 raise VCSException("Git fetch failed", p.output)
614 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
615 if p.returncode != 0:
616 raise VCSException("Git fetch failed", p.output)
617 # Recreate origin/HEAD as git clone would do it, in case it disappeared
618 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
619 if p.returncode != 0:
620 lines = p.output.splitlines()
621 if 'Multiple remote HEAD branches' not in lines[0]:
622 raise VCSException("Git remote set-head failed", p.output)
623 branch = lines[1].split(' ')[-1]
624 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
625 if p2.returncode != 0:
626 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
627 self.refreshed = True
628 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
629 # a github repo. Most of the time this is the same as origin/master.
630 rev = rev or 'origin/HEAD'
631 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
632 if p.returncode != 0:
633 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
634 # Get rid of any uncontrolled files left behind
635 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
636 if p.returncode != 0:
637 raise VCSException("Git clean failed", p.output)
639 def initsubmodules(self):
641 submfile = os.path.join(self.local, '.gitmodules')
642 if not os.path.isfile(submfile):
643 raise VCSException("No git submodules available")
645 # fix submodules not accessible without an account and public key auth
646 with open(submfile, 'r') as f:
647 lines = f.readlines()
648 with open(submfile, 'w') as f:
650 if 'git@github.com' in line:
651 line = line.replace('git@github.com:', 'https://github.com/')
652 if 'git@gitlab.com' in line:
653 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
656 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
657 if p.returncode != 0:
658 raise VCSException("Git submodule sync failed", p.output)
659 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
660 if p.returncode != 0:
661 raise VCSException("Git submodule update failed", p.output)
665 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
666 return p.output.splitlines()
668 def latesttags(self, tags, number):
673 ['git', 'show', '--format=format:%ct', '-s', tag],
674 cwd=self.local, output=False)
675 # Timestamp is on the last line. For a normal tag, it's the only
676 # line, but for annotated tags, the rest of the info precedes it.
677 ts = int(p.output.splitlines()[-1])
680 for _, t in sorted(tl)[-number:]:
685 class vcs_gitsvn(vcs):
690 # If the local directory exists, but is somehow not a git repository, git
691 # will traverse up the directory tree until it finds one that is (i.e.
692 # fdroidserver) and then we'll proceed to destory it! This is called as
695 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
696 result = p.output.rstrip()
697 if not result.endswith(self.local):
698 raise VCSException('Repository mismatch')
700 def gotorevisionx(self, rev):
701 if not os.path.exists(self.local):
703 gitsvn_args = ['git', 'svn', 'clone']
704 if ';' in self.remote:
705 remote_split = self.remote.split(';')
706 for i in remote_split[1:]:
707 if i.startswith('trunk='):
708 gitsvn_args.extend(['-T', i[6:]])
709 elif i.startswith('tags='):
710 gitsvn_args.extend(['-t', i[5:]])
711 elif i.startswith('branches='):
712 gitsvn_args.extend(['-b', i[9:]])
713 gitsvn_args.extend([remote_split[0], self.local])
714 p = FDroidPopen(gitsvn_args, output=False)
715 if p.returncode != 0:
716 self.clone_failed = True
717 raise VCSException("Git svn clone failed", p.output)
719 gitsvn_args.extend([self.remote, self.local])
720 p = FDroidPopen(gitsvn_args, output=False)
721 if p.returncode != 0:
722 self.clone_failed = True
723 raise VCSException("Git svn clone failed", p.output)
727 # Discard any working tree changes
728 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
729 if p.returncode != 0:
730 raise VCSException("Git reset failed", p.output)
731 # Remove untracked files now, in case they're tracked in the target
732 # revision (it happens!)
733 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
734 if p.returncode != 0:
735 raise VCSException("Git clean failed", p.output)
736 if not self.refreshed:
737 # Get new commits, branches and tags from repo
738 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
739 if p.returncode != 0:
740 raise VCSException("Git svn fetch failed")
741 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
742 if p.returncode != 0:
743 raise VCSException("Git svn rebase failed", p.output)
744 self.refreshed = True
746 rev = rev or 'master'
748 nospaces_rev = rev.replace(' ', '%20')
749 # Try finding a svn tag
750 for treeish in ['origin/', '']:
751 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
752 if p.returncode == 0:
754 if p.returncode != 0:
755 # No tag found, normal svn rev translation
756 # Translate svn rev into git format
757 rev_split = rev.split('/')
760 for treeish in ['origin/', '']:
761 if len(rev_split) > 1:
762 treeish += rev_split[0]
763 svn_rev = rev_split[1]
766 # if no branch is specified, then assume trunk (i.e. 'master' branch):
770 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
772 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
773 git_rev = p.output.rstrip()
775 if p.returncode == 0 and git_rev:
778 if p.returncode != 0 or not git_rev:
779 # Try a plain git checkout as a last resort
780 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
781 if p.returncode != 0:
782 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
784 # Check out the git rev equivalent to the svn rev
785 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
786 if p.returncode != 0:
787 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
789 # Get rid of any uncontrolled files left behind
790 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
791 if p.returncode != 0:
792 raise VCSException("Git clean failed", p.output)
796 for treeish in ['origin/', '']:
797 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
803 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
804 if p.returncode != 0:
806 return p.output.strip()
814 def gotorevisionx(self, rev):
815 if not os.path.exists(self.local):
816 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
817 if p.returncode != 0:
818 self.clone_failed = True
819 raise VCSException("Hg clone failed", p.output)
821 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
822 if p.returncode != 0:
823 raise VCSException("Hg status failed", p.output)
824 for line in p.output.splitlines():
825 if not line.startswith('? '):
826 raise VCSException("Unexpected output from hg status -uS: " + line)
827 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
828 if not self.refreshed:
829 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
830 if p.returncode != 0:
831 raise VCSException("Hg pull failed", p.output)
832 self.refreshed = True
834 rev = rev or 'default'
837 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
838 if p.returncode != 0:
839 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
840 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
841 # Also delete untracked files, we have to enable purge extension for that:
842 if "'purge' is provided by the following extension" in p.output:
843 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
844 myfile.write("\n[extensions]\nhgext.purge=\n")
845 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
846 if p.returncode != 0:
847 raise VCSException("HG purge failed", p.output)
848 elif p.returncode != 0:
849 raise VCSException("HG purge failed", p.output)
852 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
853 return p.output.splitlines()[1:]
861 def gotorevisionx(self, rev):
862 if not os.path.exists(self.local):
863 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
864 if p.returncode != 0:
865 self.clone_failed = True
866 raise VCSException("Bzr branch failed", p.output)
868 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("Bzr revert failed", p.output)
871 if not self.refreshed:
872 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
873 if p.returncode != 0:
874 raise VCSException("Bzr update failed", p.output)
875 self.refreshed = True
877 revargs = list(['-r', rev] if rev else [])
878 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
879 if p.returncode != 0:
880 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
883 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
884 return [tag.split(' ')[0].strip() for tag in
885 p.output.splitlines()]
888 def unescape_string(string):
891 if string[0] == '"' and string[-1] == '"':
894 return string.replace("\\'", "'")
897 def retrieve_string(app_dir, string, xmlfiles=None):
899 if not string.startswith('@string/'):
900 return unescape_string(string)
905 os.path.join(app_dir, 'res'),
906 os.path.join(app_dir, 'src', 'main', 'res'),
908 for r, d, f in os.walk(res_dir):
909 if os.path.basename(r) == 'values':
910 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
912 name = string[len('@string/'):]
914 def element_content(element):
915 if element.text is None:
917 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
920 for path in xmlfiles:
921 if not os.path.isfile(path):
923 xml = parse_xml(path)
924 element = xml.find('string[@name="' + name + '"]')
925 if element is not None:
926 content = element_content(element)
927 return retrieve_string(app_dir, content, xmlfiles)
932 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
933 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
936 # Return list of existing files that will be used to find the highest vercode
937 def manifest_paths(app_dir, flavours):
939 possible_manifests = \
940 [os.path.join(app_dir, 'AndroidManifest.xml'),
941 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
942 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
943 os.path.join(app_dir, 'build.gradle')]
945 for flavour in flavours:
948 possible_manifests.append(
949 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
951 return [path for path in possible_manifests if os.path.isfile(path)]
954 # Retrieve the package name. Returns the name, or None if not found.
955 def fetch_real_name(app_dir, flavours):
956 for path in manifest_paths(app_dir, flavours):
957 if not has_extension(path, 'xml') or not os.path.isfile(path):
959 logging.debug("fetch_real_name: Checking manifest at " + path)
960 xml = parse_xml(path)
961 app = xml.find('application')
962 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
964 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
965 result = retrieve_string_singleline(app_dir, label)
967 result = result.strip()
972 def get_library_references(root_dir):
974 proppath = os.path.join(root_dir, 'project.properties')
975 if not os.path.isfile(proppath):
977 for line in file(proppath):
978 if not line.startswith('android.library.reference.'):
980 path = line.split('=')[1].strip()
981 relpath = os.path.join(root_dir, path)
982 if not os.path.isdir(relpath):
984 logging.debug("Found subproject at %s" % path)
985 libraries.append(path)
989 def ant_subprojects(root_dir):
990 subprojects = get_library_references(root_dir)
991 for subpath in subprojects:
992 subrelpath = os.path.join(root_dir, subpath)
993 for p in get_library_references(subrelpath):
994 relp = os.path.normpath(os.path.join(subpath, p))
995 if relp not in subprojects:
996 subprojects.insert(0, relp)
1000 def remove_debuggable_flags(root_dir):
1001 # Remove forced debuggable flags
1002 logging.debug("Removing debuggable flags from %s" % root_dir)
1003 for root, dirs, files in os.walk(root_dir):
1004 if 'AndroidManifest.xml' in files:
1005 regsub_file(r'android:debuggable="[^"]*"',
1007 os.path.join(root, 'AndroidManifest.xml'))
1010 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1011 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1012 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1015 def app_matches_packagename(app, package):
1018 appid = app['Update Check Name'] or app['id']
1019 if appid == "Ignore":
1021 return appid == package
1024 # Extract some information from the AndroidManifest.xml at the given path.
1025 # Returns (version, vercode, package), any or all of which might be None.
1026 # All values returned are strings.
1027 def parse_androidmanifests(paths, app):
1029 ignoreversions = app['Update Check Ignore']
1030 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1033 return (None, None, None)
1041 if not os.path.isfile(path):
1044 logging.debug("Parsing manifest at {0}".format(path))
1045 gradle = has_extension(path, 'gradle')
1051 for line in file(path):
1052 if gradle_comment.match(line):
1054 # Grab first occurence of each to avoid running into
1055 # alternative flavours and builds.
1057 matches = psearch_g(line)
1059 s = matches.group(2)
1060 if app_matches_packagename(app, s):
1063 matches = vnsearch_g(line)
1065 version = matches.group(2)
1067 matches = vcsearch_g(line)
1069 vercode = matches.group(1)
1071 xml = parse_xml(path)
1072 if "package" in xml.attrib:
1073 s = xml.attrib["package"].encode('utf-8')
1074 if app_matches_packagename(app, s):
1076 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1077 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1078 base_dir = os.path.dirname(path)
1079 version = retrieve_string_singleline(base_dir, version)
1080 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1081 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1082 if string_is_integer(a):
1085 # Remember package name, may be defined separately from version+vercode
1087 package = max_package
1089 logging.debug("..got package={0}, version={1}, vercode={2}"
1090 .format(package, version, vercode))
1092 # Always grab the package name and version name in case they are not
1093 # together with the highest version code
1094 if max_package is None and package is not None:
1095 max_package = package
1096 if max_version is None and version is not None:
1097 max_version = version
1099 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1100 if not ignoresearch or not ignoresearch(version):
1101 if version is not None:
1102 max_version = version
1103 if vercode is not None:
1104 max_vercode = vercode
1105 if package is not None:
1106 max_package = package
1108 max_version = "Ignore"
1110 if max_version is None:
1111 max_version = "Unknown"
1113 if max_package and not is_valid_package_name(max_package):
1114 raise FDroidException("Invalid package name {0}".format(max_package))
1116 return (max_version, max_vercode, max_package)
1119 def is_valid_package_name(name):
1120 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1123 class FDroidException(Exception):
1125 def __init__(self, value, detail=None):
1127 self.detail = detail
1129 def shortened_detail(self):
1130 if len(self.detail) < 16000:
1132 return '[...]\n' + self.detail[-16000:]
1134 def get_wikitext(self):
1135 ret = repr(self.value) + "\n"
1138 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1144 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1148 class VCSException(FDroidException):
1152 class BuildException(FDroidException):
1156 # Get the specified source library.
1157 # Returns the path to it. Normally this is the path to be used when referencing
1158 # it, which may be a subdirectory of the actual project. If you want the base
1159 # directory of the project, pass 'basepath=True'.
1160 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1161 raw=False, prepare=True, preponly=False, refresh=True):
1169 name, ref = spec.split('@')
1171 number, name = name.split(':', 1)
1173 name, subdir = name.split('/', 1)
1175 if name not in metadata.srclibs:
1176 raise VCSException('srclib ' + name + ' not found.')
1178 srclib = metadata.srclibs[name]
1180 sdir = os.path.join(srclib_dir, name)
1183 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1184 vcs.srclib = (name, number, sdir)
1186 vcs.gotorevision(ref, refresh)
1193 libdir = os.path.join(sdir, subdir)
1194 elif srclib["Subdir"]:
1195 for subdir in srclib["Subdir"]:
1196 libdir_candidate = os.path.join(sdir, subdir)
1197 if os.path.exists(libdir_candidate):
1198 libdir = libdir_candidate
1204 remove_signing_keys(sdir)
1205 remove_debuggable_flags(sdir)
1209 if srclib["Prepare"]:
1210 cmd = replace_config_vars(srclib["Prepare"], None)
1212 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1213 if p.returncode != 0:
1214 raise BuildException("Error running prepare command for srclib %s"
1220 return (name, number, libdir)
1222 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1225 # Prepare the source code for a particular build
1226 # 'vcs' - the appropriate vcs object for the application
1227 # 'app' - the application details from the metadata
1228 # 'build' - the build details from the metadata
1229 # 'build_dir' - the path to the build directory, usually
1231 # 'srclib_dir' - the path to the source libraries directory, usually
1233 # 'extlib_dir' - the path to the external libraries directory, usually
1235 # Returns the (root, srclibpaths) where:
1236 # 'root' is the root directory, which may be the same as 'build_dir' or may
1237 # be a subdirectory of it.
1238 # 'srclibpaths' is information on the srclibs being used
1239 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1241 # Optionally, the actual app source can be in a subdirectory
1243 root_dir = os.path.join(build_dir, build['subdir'])
1245 root_dir = build_dir
1247 # Get a working copy of the right revision
1248 logging.info("Getting source for revision " + build['commit'])
1249 vcs.gotorevision(build['commit'], refresh)
1251 # Initialise submodules if required
1252 if build['submodules']:
1253 logging.info("Initialising submodules")
1254 vcs.initsubmodules()
1256 # Check that a subdir (if we're using one) exists. This has to happen
1257 # after the checkout, since it might not exist elsewhere
1258 if not os.path.exists(root_dir):
1259 raise BuildException('Missing subdir ' + root_dir)
1261 # Run an init command if one is required
1263 cmd = replace_config_vars(build['init'], build)
1264 logging.info("Running 'init' commands in %s" % root_dir)
1266 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1267 if p.returncode != 0:
1268 raise BuildException("Error running init command for %s:%s" %
1269 (app['id'], build['version']), p.output)
1271 # Apply patches if any
1273 logging.info("Applying patches")
1274 for patch in build['patch']:
1275 patch = patch.strip()
1276 logging.info("Applying " + patch)
1277 patch_path = os.path.join('metadata', app['id'], patch)
1278 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1279 if p.returncode != 0:
1280 raise BuildException("Failed to apply patch %s" % patch_path)
1282 # Get required source libraries
1284 if build['srclibs']:
1285 logging.info("Collecting source libraries")
1286 for lib in build['srclibs']:
1287 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1289 for name, number, libpath in srclibpaths:
1290 place_srclib(root_dir, int(number) if number else None, libpath)
1292 basesrclib = vcs.getsrclib()
1293 # If one was used for the main source, add that too.
1295 srclibpaths.append(basesrclib)
1297 # Update the local.properties file
1298 localprops = [os.path.join(build_dir, 'local.properties')]
1300 parts = build['subdir'].split(os.sep)
1303 cur = os.path.join(cur, d)
1304 localprops += [os.path.join(cur, 'local.properties')]
1305 for path in localprops:
1307 if os.path.isfile(path):
1308 logging.info("Updating local.properties file at %s" % path)
1309 with open(path, 'r') as f:
1313 logging.info("Creating local.properties file at %s" % path)
1314 # Fix old-fashioned 'sdk-location' by copying
1315 # from sdk.dir, if necessary
1316 if build['oldsdkloc']:
1317 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1318 re.S | re.M).group(1)
1319 props += "sdk-location=%s\n" % sdkloc
1321 props += "sdk.dir=%s\n" % config['sdk_path']
1322 props += "sdk-location=%s\n" % config['sdk_path']
1323 if build['ndk_path']:
1325 props += "ndk.dir=%s\n" % build['ndk_path']
1326 props += "ndk-location=%s\n" % build['ndk_path']
1327 # Add java.encoding if necessary
1328 if build['encoding']:
1329 props += "java.encoding=%s\n" % build['encoding']
1330 with open(path, 'w') as f:
1334 if build['type'] == 'gradle':
1335 flavours = build['gradle']
1337 gradlepluginver = None
1339 gradle_dirs = [root_dir]
1341 # Parent dir build.gradle
1342 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1343 if parent_dir.startswith(build_dir):
1344 gradle_dirs.append(parent_dir)
1346 for dir_path in gradle_dirs:
1349 if not os.path.isdir(dir_path):
1351 for filename in os.listdir(dir_path):
1352 if not filename.endswith('.gradle'):
1354 path = os.path.join(dir_path, filename)
1355 if not os.path.isfile(path):
1357 for line in file(path):
1358 match = gradle_version_regex.match(line)
1360 gradlepluginver = match.group(1)
1364 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1366 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1367 build['gradlepluginver'] = LooseVersion('0.11')
1370 n = build["target"].split('-')[1]
1371 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1372 r'compileSdkVersion %s' % n,
1373 os.path.join(root_dir, 'build.gradle'))
1375 # Remove forced debuggable flags
1376 remove_debuggable_flags(root_dir)
1378 # Insert version code and number into the manifest if necessary
1379 if build['forceversion']:
1380 logging.info("Changing the version name")
1381 for path in manifest_paths(root_dir, flavours):
1382 if not os.path.isfile(path):
1384 if has_extension(path, 'xml'):
1385 regsub_file(r'android:versionName="[^"]*"',
1386 r'android:versionName="%s"' % build['version'],
1388 elif has_extension(path, 'gradle'):
1389 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1390 r"""\1versionName '%s'""" % build['version'],
1393 if build['forcevercode']:
1394 logging.info("Changing the version code")
1395 for path in manifest_paths(root_dir, flavours):
1396 if not os.path.isfile(path):
1398 if has_extension(path, 'xml'):
1399 regsub_file(r'android:versionCode="[^"]*"',
1400 r'android:versionCode="%s"' % build['vercode'],
1402 elif has_extension(path, 'gradle'):
1403 regsub_file(r'versionCode[ =]+[0-9]+',
1404 r'versionCode %s' % build['vercode'],
1407 # Delete unwanted files
1409 logging.info("Removing specified files")
1410 for part in getpaths(build_dir, build['rm']):
1411 dest = os.path.join(build_dir, part)
1412 logging.info("Removing {0}".format(part))
1413 if os.path.lexists(dest):
1414 if os.path.islink(dest):
1415 FDroidPopen(['unlink', dest], output=False)
1417 FDroidPopen(['rm', '-rf', dest], output=False)
1419 logging.info("...but it didn't exist")
1421 remove_signing_keys(build_dir)
1423 # Add required external libraries
1424 if build['extlibs']:
1425 logging.info("Collecting prebuilt libraries")
1426 libsdir = os.path.join(root_dir, 'libs')
1427 if not os.path.exists(libsdir):
1429 for lib in build['extlibs']:
1431 logging.info("...installing extlib {0}".format(lib))
1432 libf = os.path.basename(lib)
1433 libsrc = os.path.join(extlib_dir, lib)
1434 if not os.path.exists(libsrc):
1435 raise BuildException("Missing extlib file {0}".format(libsrc))
1436 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1438 # Run a pre-build command if one is required
1439 if build['prebuild']:
1440 logging.info("Running 'prebuild' commands in %s" % root_dir)
1442 cmd = replace_config_vars(build['prebuild'], build)
1444 # Substitute source library paths into prebuild commands
1445 for name, number, libpath in srclibpaths:
1446 libpath = os.path.relpath(libpath, root_dir)
1447 cmd = cmd.replace('$$' + name + '$$', libpath)
1449 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1450 if p.returncode != 0:
1451 raise BuildException("Error running prebuild command for %s:%s" %
1452 (app['id'], build['version']), p.output)
1454 # Generate (or update) the ant build file, build.xml...
1455 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1456 parms = ['android', 'update', 'lib-project']
1457 lparms = ['android', 'update', 'project']
1460 parms += ['-t', build['target']]
1461 lparms += ['-t', build['target']]
1462 if build['update'] == ['auto']:
1463 update_dirs = ant_subprojects(root_dir) + ['.']
1465 update_dirs = build['update']
1467 for d in update_dirs:
1468 subdir = os.path.join(root_dir, d)
1470 logging.debug("Updating main project")
1471 cmd = parms + ['-p', d]
1473 logging.debug("Updating subproject %s" % d)
1474 cmd = lparms + ['-p', d]
1475 p = SdkToolsPopen(cmd, cwd=root_dir)
1476 # Check to see whether an error was returned without a proper exit
1477 # code (this is the case for the 'no target set or target invalid'
1479 if p.returncode != 0 or p.output.startswith("Error: "):
1480 raise BuildException("Failed to update project at %s" % d, p.output)
1481 # Clean update dirs via ant
1483 logging.info("Cleaning subproject %s" % d)
1484 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1486 return (root_dir, srclibpaths)
1489 # Extend via globbing the paths from a field and return them as a map from
1490 # original path to resulting paths
1491 def getpaths_map(build_dir, globpaths):
1495 full_path = os.path.join(build_dir, p)
1496 full_path = os.path.normpath(full_path)
1497 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1501 # Extend via globbing the paths from a field and return them as a set
1502 def getpaths(build_dir, globpaths):
1503 paths_map = getpaths_map(build_dir, globpaths)
1505 for k, v in paths_map.iteritems():
1512 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1518 self.path = os.path.join('stats', 'known_apks.txt')
1520 if os.path.isfile(self.path):
1521 for line in file(self.path):
1522 t = line.rstrip().split(' ')
1524 self.apks[t[0]] = (t[1], None)
1526 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1527 self.changed = False
1529 def writeifchanged(self):
1530 if not self.changed:
1533 if not os.path.exists('stats'):
1537 for apk, app in self.apks.iteritems():
1539 line = apk + ' ' + appid
1541 line += ' ' + time.strftime('%Y-%m-%d', added)
1544 with open(self.path, 'w') as f:
1545 for line in sorted(lst, key=natural_key):
1546 f.write(line + '\n')
1548 # Record an apk (if it's new, otherwise does nothing)
1549 # Returns the date it was added.
1550 def recordapk(self, apk, app):
1551 if apk not in self.apks:
1552 self.apks[apk] = (app, time.gmtime(time.time()))
1554 _, added = self.apks[apk]
1557 # Look up information - given the 'apkname', returns (app id, date added/None).
1558 # Or returns None for an unknown apk.
1559 def getapp(self, apkname):
1560 if apkname in self.apks:
1561 return self.apks[apkname]
1564 # Get the most recent 'num' apps added to the repo, as a list of package ids
1565 # with the most recent first.
1566 def getlatest(self, num):
1568 for apk, app in self.apks.iteritems():
1572 if apps[appid] > added:
1576 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1577 lst = [app for app, _ in sortedapps]
1582 def isApkDebuggable(apkfile, config):
1583 """Returns True if the given apk file is debuggable
1585 :param apkfile: full path to the apk to check"""
1587 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1589 if p.returncode != 0:
1590 logging.critical("Failed to get apk manifest information")
1592 for line in p.output.splitlines():
1593 if 'android:debuggable' in line and not line.endswith('0x0'):
1603 def SdkToolsPopen(commands, cwd=None, output=True):
1605 if cmd not in config:
1606 config[cmd] = find_sdk_tools_cmd(commands[0])
1607 abscmd = config[cmd]
1609 logging.critical("Could not find '%s' on your system" % cmd)
1611 return FDroidPopen([abscmd] + commands[1:],
1612 cwd=cwd, output=output)
1615 def FDroidPopen(commands, cwd=None, output=True):
1617 Run a command and capture the possibly huge output.
1619 :param commands: command and argument list like in subprocess.Popen
1620 :param cwd: optionally specifies a working directory
1621 :returns: A PopenResult.
1627 cwd = os.path.normpath(cwd)
1628 logging.debug("Directory: %s" % cwd)
1629 logging.debug("> %s" % ' '.join(commands))
1631 result = PopenResult()
1634 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1635 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1637 raise BuildException("OSError while trying to execute " +
1638 ' '.join(commands) + ': ' + str(e))
1640 stdout_queue = Queue.Queue()
1641 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1643 # Check the queue for output (until there is no more to get)
1644 while not stdout_reader.eof():
1645 while not stdout_queue.empty():
1646 line = stdout_queue.get()
1647 if output and options.verbose:
1648 # Output directly to console
1649 sys.stderr.write(line)
1651 result.output += line
1655 result.returncode = p.wait()
1659 gradle_comment = re.compile(r'[ ]*//')
1660 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1661 gradle_line_matches = [
1662 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1663 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1664 re.compile(r'.*variant\.outputFile = .*'),
1665 re.compile(r'.*output\.outputFile = .*'),
1666 re.compile(r'.*\.readLine\(.*'),
1670 def remove_signing_keys(build_dir):
1671 for root, dirs, files in os.walk(build_dir):
1672 if 'build.gradle' in files:
1673 path = os.path.join(root, 'build.gradle')
1675 with open(path, "r") as o:
1676 lines = o.readlines()
1682 with open(path, "w") as o:
1683 while i < len(lines):
1686 while line.endswith('\\\n'):
1687 line = line.rstrip('\\\n') + lines[i]
1690 if gradle_comment.match(line):
1695 opened += line.count('{')
1696 opened -= line.count('}')
1699 if gradle_signing_configs.match(line):
1704 if any(s.match(line) for s in gradle_line_matches):
1712 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1715 'project.properties',
1717 'default.properties',
1718 'ant.properties', ]:
1719 if propfile in files:
1720 path = os.path.join(root, propfile)
1722 with open(path, "r") as o:
1723 lines = o.readlines()
1727 with open(path, "w") as o:
1729 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1736 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1739 def reset_env_path():
1740 global env, orig_path
1741 env['PATH'] = orig_path
1744 def add_to_env_path(path):
1746 paths = env['PATH'].split(os.pathsep)
1750 env['PATH'] = os.pathsep.join(paths)
1753 def replace_config_vars(cmd, build):
1755 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1756 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1757 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1758 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1759 if build is not None:
1760 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1761 cmd = cmd.replace('$$VERSION$$', build['version'])
1762 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1766 def place_srclib(root_dir, number, libpath):
1769 relpath = os.path.relpath(libpath, root_dir)
1770 proppath = os.path.join(root_dir, 'project.properties')
1773 if os.path.isfile(proppath):
1774 with open(proppath, "r") as o:
1775 lines = o.readlines()
1777 with open(proppath, "w") as o:
1780 if line.startswith('android.library.reference.%d=' % number):
1781 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1786 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1788 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1791 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1792 """Verify that two apks are the same
1794 One of the inputs is signed, the other is unsigned. The signature metadata
1795 is transferred from the signed to the unsigned apk, and then jarsigner is
1796 used to verify that the signature from the signed apk is also varlid for
1798 :param signed_apk: Path to a signed apk file
1799 :param unsigned_apk: Path to an unsigned apk file expected to match it
1800 :param tmp_dir: Path to directory for temporary files
1801 :returns: None if the verification is successful, otherwise a string
1802 describing what went wrong.
1804 with ZipFile(signed_apk) as signed_apk_as_zip:
1805 meta_inf_files = ['META-INF/MANIFEST.MF']
1806 for f in signed_apk_as_zip.namelist():
1807 if apk_sigfile.match(f):
1808 meta_inf_files.append(f)
1809 if len(meta_inf_files) < 3:
1810 return "Signature files missing from {0}".format(signed_apk)
1811 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1812 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1813 for meta_inf_file in meta_inf_files:
1814 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1816 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1817 logging.info("...NOT verified - {0}".format(signed_apk))
1818 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1819 logging.info("...successfully verified")
1822 apk_badchars = re.compile('''[/ :;'"]''')
1825 def compare_apks(apk1, apk2, tmp_dir):
1828 Returns None if the apk content is the same (apart from the signing key),
1829 otherwise a string describing what's different, or what went wrong when
1830 trying to do the comparison.
1833 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1834 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1835 for d in [apk1dir, apk2dir]:
1836 if os.path.exists(d):
1839 os.mkdir(os.path.join(d, 'jar-xf'))
1841 if subprocess.call(['jar', 'xf',
1842 os.path.abspath(apk1)],
1843 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1844 return("Failed to unpack " + apk1)
1845 if subprocess.call(['jar', 'xf',
1846 os.path.abspath(apk2)],
1847 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1848 return("Failed to unpack " + apk2)
1850 # try to find apktool in the path, if it hasn't been manually configed
1851 if 'apktool' not in config:
1852 tmp = find_command('apktool')
1854 config['apktool'] = tmp
1855 if 'apktool' in config:
1856 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1858 return("Failed to unpack " + apk1)
1859 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1861 return("Failed to unpack " + apk2)
1863 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1864 lines = p.output.splitlines()
1865 if len(lines) != 1 or 'META-INF' not in lines[0]:
1866 meld = find_command('meld')
1867 if meld is not None:
1868 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1869 return("Unexpected diff output - " + p.output)
1871 # since everything verifies, delete the comparison to keep cruft down
1872 shutil.rmtree(apk1dir)
1873 shutil.rmtree(apk2dir)
1875 # If we get here, it seems like they're the same!
1879 def find_command(command):
1880 '''find the full path of a command, or None if it can't be found in the PATH'''
1883 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1885 fpath, fname = os.path.split(command)
1890 for path in os.environ["PATH"].split(os.pathsep):
1891 path = path.strip('"')
1892 exe_file = os.path.join(path, command)
1893 if is_exe(exe_file):
1900 '''generate a random password for when generating keys'''
1901 h = hashlib.sha256()
1902 h.update(os.urandom(16)) # salt
1903 h.update(bytes(socket.getfqdn()))
1904 return h.digest().encode('base64').strip()
1907 def genkeystore(localconfig):
1908 '''Generate a new key with random passwords and add it to new keystore'''
1909 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1910 keystoredir = os.path.dirname(localconfig['keystore'])
1911 if keystoredir is None or keystoredir == '':
1912 keystoredir = os.path.join(os.getcwd(), keystoredir)
1913 if not os.path.exists(keystoredir):
1914 os.makedirs(keystoredir, mode=0o700)
1916 write_password_file("keystorepass", localconfig['keystorepass'])
1917 write_password_file("keypass", localconfig['keypass'])
1918 p = FDroidPopen(['keytool', '-genkey',
1919 '-keystore', localconfig['keystore'],
1920 '-alias', localconfig['repo_keyalias'],
1921 '-keyalg', 'RSA', '-keysize', '4096',
1922 '-sigalg', 'SHA256withRSA',
1923 '-validity', '10000',
1924 '-storepass:file', config['keystorepassfile'],
1925 '-keypass:file', config['keypassfile'],
1926 '-dname', localconfig['keydname']])
1927 # TODO keypass should be sent via stdin
1928 if p.returncode != 0:
1929 raise BuildException("Failed to generate key", p.output)
1930 os.chmod(localconfig['keystore'], 0o0600)
1931 # now show the lovely key that was just generated
1932 p = FDroidPopen(['keytool', '-list', '-v',
1933 '-keystore', localconfig['keystore'],
1934 '-alias', localconfig['repo_keyalias'],
1935 '-storepass:file', config['keystorepassfile']])
1936 logging.info(p.output.strip() + '\n\n')
1939 def write_to_config(thisconfig, key, value=None):
1940 '''write a key/value to the local config.py'''
1942 origkey = key + '_orig'
1943 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1944 with open('config.py', 'r') as f:
1946 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1947 repl = '\n' + key + ' = "' + value + '"'
1948 data = re.sub(pattern, repl, data)
1949 # if this key is not in the file, append it
1950 if not re.match('\s*' + key + '\s*=\s*"', data):
1952 # make sure the file ends with a carraige return
1953 if not re.match('\n$', data):
1955 with open('config.py', 'w') as f:
1959 def parse_xml(path):
1960 return XMLElementTree.parse(path).getroot()
1963 def string_is_integer(string):
1971 def get_per_app_repos():
1972 '''per-app repos are dirs named with the packageName of a single app'''
1974 # Android packageNames are Java packages, they may contain uppercase or
1975 # lowercase letters ('A' through 'Z'), numbers, and underscores
1976 # ('_'). However, individual package name parts may only start with
1977 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1978 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1981 for root, dirs, files in os.walk(os.getcwd()):
1983 print 'checking', root, 'for', d
1984 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1985 # standard parts of an fdroid repo, so never packageNames
1988 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):