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.
37 import xml.etree.ElementTree as XMLElementTree
39 from distutils.version import LooseVersion
40 from zipfile import ZipFile
44 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
53 'sdk_path': "$ANDROID_HOME",
56 'r10e': "$ANDROID_NDK",
58 'build_tools': "23.0.1",
62 'accepted_formats': ['txt', 'yaml'],
63 'sync_from_local_copy_dir': False,
64 'per_app_repos': False,
65 'make_current_version_link': True,
66 'current_version_name_source': 'Name',
67 'update_stats': False,
71 'stats_to_carbon': False,
73 'build_server_always': False,
74 'keystore': 'keystore.jks',
75 'smartcardoptions': [],
81 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
82 'repo_name': "My First FDroid Repo Demo",
83 'repo_icon': "fdroid-icon.png",
84 'repo_description': '''
85 This is a repository of apps to be used with FDroid. Applications in this
86 repository are either official binaries built by the original application
87 developers, or are binaries built from source by the admin of f-droid.org
88 using the tools on https://gitlab.com/u/fdroid.
94 def setup_global_opts(parser):
95 parser.add_argument("-v", "--verbose", action="store_true", default=False,
96 help="Spew out even more information than normal")
97 parser.add_argument("-q", "--quiet", action="store_true", default=False,
98 help="Restrict output to warnings and errors")
101 def fill_config_defaults(thisconfig):
102 for k, v in default_config.items():
103 if k not in thisconfig:
106 # Expand paths (~users and $vars)
107 def expand_path(path):
111 path = os.path.expanduser(path)
112 path = os.path.expandvars(path)
117 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
122 thisconfig[k + '_orig'] = v
124 for k in ['ndk_paths']:
130 thisconfig[k][k2] = exp
131 thisconfig[k][k2 + '_orig'] = v
134 def regsub_file(pattern, repl, path):
135 with open(path, 'r') as f:
137 text = re.sub(pattern, repl, text)
138 with open(path, 'w') as f:
142 def read_config(opts, config_file='config.py'):
143 """Read the repository config
145 The config is read from config_file, which is in the current directory when
146 any of the repo management commands are used.
148 global config, options, env, orig_path
150 if config is not None:
152 if not os.path.isfile(config_file):
153 logging.critical("Missing config file - is this a repo directory?")
160 logging.debug("Reading %s" % config_file)
161 execfile(config_file, config)
163 # smartcardoptions must be a list since its command line args for Popen
164 if 'smartcardoptions' in config:
165 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
166 elif 'keystore' in config and config['keystore'] == 'NONE':
167 # keystore='NONE' means use smartcard, these are required defaults
168 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
169 'SunPKCS11-OpenSC', '-providerClass',
170 'sun.security.pkcs11.SunPKCS11',
171 '-providerArg', 'opensc-fdroid.cfg']
173 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
174 st = os.stat(config_file)
175 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
176 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
178 fill_config_defaults(config)
180 # There is no standard, so just set up the most common environment
183 orig_path = env['PATH']
184 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
185 env[n] = config['sdk_path']
187 for k in ["keystorepass", "keypass"]:
189 write_password_file(k)
191 for k in ["repo_description", "archive_description"]:
193 config[k] = clean_description(config[k])
195 if 'serverwebroot' in config:
196 if isinstance(config['serverwebroot'], basestring):
197 roots = [config['serverwebroot']]
198 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
199 roots = config['serverwebroot']
201 raise TypeError('only accepts strings, lists, and tuples')
203 for rootstr in roots:
204 # since this is used with rsync, where trailing slashes have
205 # meaning, ensure there is always a trailing slash
206 if rootstr[-1] != '/':
208 rootlist.append(rootstr.replace('//', '/'))
209 config['serverwebroot'] = rootlist
214 def get_ndk_path(version):
216 version = 'r10e' # falls back to latest
217 paths = config['ndk_paths']
218 if version not in paths:
220 return paths[version] or ''
223 def find_sdk_tools_cmd(cmd):
224 '''find a working path to a tool from the Android SDK'''
227 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
228 # try to find a working path to this command, in all the recent possible paths
229 if 'build_tools' in config:
230 build_tools = os.path.join(config['sdk_path'], 'build-tools')
231 # if 'build_tools' was manually set and exists, check only that one
232 configed_build_tools = os.path.join(build_tools, config['build_tools'])
233 if os.path.exists(configed_build_tools):
234 tooldirs.append(configed_build_tools)
236 # no configed version, so hunt known paths for it
237 for f in sorted(os.listdir(build_tools), reverse=True):
238 if os.path.isdir(os.path.join(build_tools, f)):
239 tooldirs.append(os.path.join(build_tools, f))
240 tooldirs.append(build_tools)
241 sdk_tools = os.path.join(config['sdk_path'], 'tools')
242 if os.path.exists(sdk_tools):
243 tooldirs.append(sdk_tools)
244 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
245 if os.path.exists(sdk_platform_tools):
246 tooldirs.append(sdk_platform_tools)
247 tooldirs.append('/usr/bin')
249 if os.path.isfile(os.path.join(d, cmd)):
250 return os.path.join(d, cmd)
251 # did not find the command, exit with error message
252 ensure_build_tools_exists(config)
255 def test_sdk_exists(thisconfig):
256 if 'sdk_path' not in thisconfig:
257 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
260 logging.error("'sdk_path' not set in config.py!")
262 if thisconfig['sdk_path'] == default_config['sdk_path']:
263 logging.error('No Android SDK found!')
264 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
265 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
267 if not os.path.exists(thisconfig['sdk_path']):
268 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
270 if not os.path.isdir(thisconfig['sdk_path']):
271 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
273 for d in ['build-tools', 'platform-tools', 'tools']:
274 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
275 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
276 thisconfig['sdk_path'], d))
281 def ensure_build_tools_exists(thisconfig):
282 if not test_sdk_exists(thisconfig):
284 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
285 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
286 if not os.path.isdir(versioned_build_tools):
287 logging.critical('Android Build Tools path "'
288 + versioned_build_tools + '" does not exist!')
292 def write_password_file(pwtype, password=None):
294 writes out passwords to a protected file instead of passing passwords as
295 command line argments
297 filename = '.fdroid.' + pwtype + '.txt'
298 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
300 os.write(fd, config[pwtype])
302 os.write(fd, password)
304 config[pwtype + 'file'] = filename
307 # Given the arguments in the form of multiple appid:[vc] strings, this returns
308 # a dictionary with the set of vercodes specified for each package.
309 def read_pkg_args(args, allow_vercodes=False):
316 if allow_vercodes and ':' in p:
317 package, vercode = p.split(':')
319 package, vercode = p, None
320 if package not in vercodes:
321 vercodes[package] = [vercode] if vercode else []
323 elif vercode and vercode not in vercodes[package]:
324 vercodes[package] += [vercode] if vercode else []
329 # On top of what read_pkg_args does, this returns the whole app metadata, but
330 # limiting the builds list to the builds matching the vercodes specified.
331 def read_app_args(args, allapps, allow_vercodes=False):
333 vercodes = read_pkg_args(args, allow_vercodes)
339 for appid, app in allapps.iteritems():
340 if appid in vercodes:
343 if len(apps) != len(vercodes):
346 logging.critical("No such package: %s" % p)
347 raise FDroidException("Found invalid app ids in arguments")
349 raise FDroidException("No packages specified")
352 for appid, app in apps.iteritems():
356 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
357 if len(app['builds']) != len(vercodes[appid]):
359 allvcs = [b['vercode'] for b in app['builds']]
360 for v in vercodes[appid]:
362 logging.critical("No such vercode %s for app %s" % (v, appid))
365 raise FDroidException("Found invalid vercodes for some apps")
370 def get_extension(filename):
371 _, ext = os.path.splitext(filename)
374 return ext.lower()[1:]
377 def has_extension(filename, ext):
378 return ext == get_extension(filename)
384 def clean_description(description):
385 'Remove unneeded newlines and spaces from a block of description text'
387 # this is split up by paragraph to make removing the newlines easier
388 for paragraph in re.split(r'\n\n', description):
389 paragraph = re.sub('\r', '', paragraph)
390 paragraph = re.sub('\n', ' ', paragraph)
391 paragraph = re.sub(' {2,}', ' ', paragraph)
392 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
393 returnstring += paragraph + '\n\n'
394 return returnstring.rstrip('\n')
397 def apknameinfo(filename):
399 filename = os.path.basename(filename)
400 if apk_regex is None:
401 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
402 m = apk_regex.match(filename)
404 result = (m.group(1), m.group(2))
405 except AttributeError:
406 raise FDroidException("Invalid apk name: %s" % filename)
410 def getapkname(app, build):
411 return "%s_%s.apk" % (app['id'], build['vercode'])
414 def getsrcname(app, build):
415 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
422 return app['Auto Name']
427 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
430 def getvcs(vcstype, remote, local):
432 return vcs_git(remote, local)
433 if vcstype == 'git-svn':
434 return vcs_gitsvn(remote, local)
436 return vcs_hg(remote, local)
438 return vcs_bzr(remote, local)
439 if vcstype == 'srclib':
440 if local != os.path.join('build', 'srclib', remote):
441 raise VCSException("Error: srclib paths are hard-coded!")
442 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
444 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
445 raise VCSException("Invalid vcs type " + vcstype)
448 def getsrclibvcs(name):
449 if name not in metadata.srclibs:
450 raise VCSException("Missing srclib " + name)
451 return metadata.srclibs[name]['Repo Type']
456 def __init__(self, remote, local):
458 # svn, git-svn and bzr may require auth
460 if self.repotype() in ('git-svn', 'bzr'):
462 if self.repotype == 'git-svn':
463 raise VCSException("Authentication is not supported for git-svn")
464 self.username, remote = remote.split('@')
465 if ':' not in self.username:
466 raise VCSException("Password required with username")
467 self.username, self.password = self.username.split(':')
471 self.clone_failed = False
472 self.refreshed = False
478 # Take the local repository to a clean version of the given revision, which
479 # is specificed in the VCS's native format. Beforehand, the repository can
480 # be dirty, or even non-existent. If the repository does already exist
481 # locally, it will be updated from the origin, but only once in the
482 # lifetime of the vcs object.
483 # None is acceptable for 'rev' if you know you are cloning a clean copy of
484 # the repo - otherwise it must specify a valid revision.
485 def gotorevision(self, rev, refresh=True):
487 if self.clone_failed:
488 raise VCSException("Downloading the repository already failed once, not trying again.")
490 # The .fdroidvcs-id file for a repo tells us what VCS type
491 # and remote that directory was created from, allowing us to drop it
492 # automatically if either of those things changes.
493 fdpath = os.path.join(self.local, '..',
494 '.fdroidvcs-' + os.path.basename(self.local))
495 cdata = self.repotype() + ' ' + self.remote
498 if os.path.exists(self.local):
499 if os.path.exists(fdpath):
500 with open(fdpath, 'r') as f:
501 fsdata = f.read().strip()
506 logging.info("Repository details for %s changed - deleting" % (
510 logging.info("Repository details for %s missing - deleting" % (
513 shutil.rmtree(self.local)
517 self.refreshed = True
520 self.gotorevisionx(rev)
521 except FDroidException, e:
524 # If necessary, write the .fdroidvcs file.
525 if writeback and not self.clone_failed:
526 with open(fdpath, 'w') as f:
532 # Derived classes need to implement this. It's called once basic checking
533 # has been performend.
534 def gotorevisionx(self, rev):
535 raise VCSException("This VCS type doesn't define gotorevisionx")
537 # Initialise and update submodules
538 def initsubmodules(self):
539 raise VCSException('Submodules not supported for this vcs type')
541 # Get a list of all known tags
543 if not self._gettags:
544 raise VCSException('gettags not supported for this vcs type')
546 for tag in self._gettags():
547 if re.match('[-A-Za-z0-9_. /]+$', tag):
551 def latesttags(self, tags, number):
552 """Get the most recent tags in a given list.
554 :param tags: a list of tags
555 :param number: the number to return
556 :returns: A list containing the most recent tags in the provided
557 list, up to the maximum number given.
559 raise VCSException('latesttags not supported for this vcs type')
561 # Get current commit reference (hash, revision, etc)
563 raise VCSException('getref not supported for this vcs type')
565 # Returns the srclib (name, path) used in setting up the current
576 # If the local directory exists, but is somehow not a git repository, git
577 # will traverse up the directory tree until it finds one that is (i.e.
578 # fdroidserver) and then we'll proceed to destroy it! This is called as
581 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
582 result = p.output.rstrip()
583 if not result.endswith(self.local):
584 raise VCSException('Repository mismatch')
586 def gotorevisionx(self, rev):
587 if not os.path.exists(self.local):
589 p = FDroidPopen(['git', 'clone', self.remote, self.local])
590 if p.returncode != 0:
591 self.clone_failed = True
592 raise VCSException("Git clone failed", p.output)
596 # Discard any working tree changes
597 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
598 'git', 'reset', '--hard'], cwd=self.local, output=False)
599 if p.returncode != 0:
600 raise VCSException("Git reset failed", p.output)
601 # Remove untracked files now, in case they're tracked in the target
602 # revision (it happens!)
603 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
604 'git', 'clean', '-dffx'], cwd=self.local, output=False)
605 if p.returncode != 0:
606 raise VCSException("Git clean failed", p.output)
607 if not self.refreshed:
608 # Get latest commits and tags from remote
609 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
610 if p.returncode != 0:
611 raise VCSException("Git fetch failed", p.output)
612 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
613 if p.returncode != 0:
614 raise VCSException("Git fetch failed", p.output)
615 # Recreate origin/HEAD as git clone would do it, in case it disappeared
616 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
617 if p.returncode != 0:
618 lines = p.output.splitlines()
619 if 'Multiple remote HEAD branches' not in lines[0]:
620 raise VCSException("Git remote set-head failed", p.output)
621 branch = lines[1].split(' ')[-1]
622 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
623 if p2.returncode != 0:
624 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
625 self.refreshed = True
626 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
627 # a github repo. Most of the time this is the same as origin/master.
628 rev = rev or 'origin/HEAD'
629 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
630 if p.returncode != 0:
631 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
632 # Get rid of any uncontrolled files left behind
633 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
634 if p.returncode != 0:
635 raise VCSException("Git clean failed", p.output)
637 def initsubmodules(self):
639 submfile = os.path.join(self.local, '.gitmodules')
640 if not os.path.isfile(submfile):
641 raise VCSException("No git submodules available")
643 # fix submodules not accessible without an account and public key auth
644 with open(submfile, 'r') as f:
645 lines = f.readlines()
646 with open(submfile, 'w') as f:
648 if 'git@github.com' in line:
649 line = line.replace('git@github.com:', 'https://github.com/')
652 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
653 if p.returncode != 0:
654 raise VCSException("Git submodule sync failed", p.output)
655 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
656 if p.returncode != 0:
657 raise VCSException("Git submodule update failed", p.output)
661 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
662 return p.output.splitlines()
664 def latesttags(self, tags, number):
669 ['git', 'show', '--format=format:%ct', '-s', tag],
670 cwd=self.local, output=False)
671 # Timestamp is on the last line. For a normal tag, it's the only
672 # line, but for annotated tags, the rest of the info precedes it.
673 ts = int(p.output.splitlines()[-1])
676 for _, t in sorted(tl)[-number:]:
681 class vcs_gitsvn(vcs):
686 # If the local directory exists, but is somehow not a git repository, git
687 # will traverse up the directory tree until it finds one that is (i.e.
688 # fdroidserver) and then we'll proceed to destory it! This is called as
691 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
692 result = p.output.rstrip()
693 if not result.endswith(self.local):
694 raise VCSException('Repository mismatch')
696 def gotorevisionx(self, rev):
697 if not os.path.exists(self.local):
699 gitsvn_args = ['git', 'svn', 'clone']
700 if ';' in self.remote:
701 remote_split = self.remote.split(';')
702 for i in remote_split[1:]:
703 if i.startswith('trunk='):
704 gitsvn_args.extend(['-T', i[6:]])
705 elif i.startswith('tags='):
706 gitsvn_args.extend(['-t', i[5:]])
707 elif i.startswith('branches='):
708 gitsvn_args.extend(['-b', i[9:]])
709 gitsvn_args.extend([remote_split[0], self.local])
710 p = FDroidPopen(gitsvn_args, output=False)
711 if p.returncode != 0:
712 self.clone_failed = True
713 raise VCSException("Git svn clone failed", p.output)
715 gitsvn_args.extend([self.remote, self.local])
716 p = FDroidPopen(gitsvn_args, output=False)
717 if p.returncode != 0:
718 self.clone_failed = True
719 raise VCSException("Git svn clone failed", p.output)
723 # Discard any working tree changes
724 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
725 if p.returncode != 0:
726 raise VCSException("Git reset failed", p.output)
727 # Remove untracked files now, in case they're tracked in the target
728 # revision (it happens!)
729 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
730 if p.returncode != 0:
731 raise VCSException("Git clean failed", p.output)
732 if not self.refreshed:
733 # Get new commits, branches and tags from repo
734 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
735 if p.returncode != 0:
736 raise VCSException("Git svn fetch failed")
737 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
738 if p.returncode != 0:
739 raise VCSException("Git svn rebase failed", p.output)
740 self.refreshed = True
742 rev = rev or 'master'
744 nospaces_rev = rev.replace(' ', '%20')
745 # Try finding a svn tag
746 for treeish in ['origin/', '']:
747 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
748 if p.returncode == 0:
750 if p.returncode != 0:
751 # No tag found, normal svn rev translation
752 # Translate svn rev into git format
753 rev_split = rev.split('/')
756 for treeish in ['origin/', '']:
757 if len(rev_split) > 1:
758 treeish += rev_split[0]
759 svn_rev = rev_split[1]
762 # if no branch is specified, then assume trunk (i.e. 'master' branch):
766 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
768 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
769 git_rev = p.output.rstrip()
771 if p.returncode == 0 and git_rev:
774 if p.returncode != 0 or not git_rev:
775 # Try a plain git checkout as a last resort
776 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
777 if p.returncode != 0:
778 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
780 # Check out the git rev equivalent to the svn rev
781 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
782 if p.returncode != 0:
783 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
785 # Get rid of any uncontrolled files left behind
786 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
787 if p.returncode != 0:
788 raise VCSException("Git clean failed", p.output)
792 for treeish in ['origin/', '']:
793 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
799 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
800 if p.returncode != 0:
802 return p.output.strip()
810 def gotorevisionx(self, rev):
811 if not os.path.exists(self.local):
812 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
813 if p.returncode != 0:
814 self.clone_failed = True
815 raise VCSException("Hg clone failed", p.output)
817 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
818 if p.returncode != 0:
819 raise VCSException("Hg status failed", p.output)
820 for line in p.output.splitlines():
821 if not line.startswith('? '):
822 raise VCSException("Unexpected output from hg status -uS: " + line)
823 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
824 if not self.refreshed:
825 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
826 if p.returncode != 0:
827 raise VCSException("Hg pull failed", p.output)
828 self.refreshed = True
830 rev = rev or 'default'
833 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
834 if p.returncode != 0:
835 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
836 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
837 # Also delete untracked files, we have to enable purge extension for that:
838 if "'purge' is provided by the following extension" in p.output:
839 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
840 myfile.write("\n[extensions]\nhgext.purge=\n")
841 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("HG purge failed", p.output)
844 elif p.returncode != 0:
845 raise VCSException("HG purge failed", p.output)
848 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
849 return p.output.splitlines()[1:]
857 def gotorevisionx(self, rev):
858 if not os.path.exists(self.local):
859 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
860 if p.returncode != 0:
861 self.clone_failed = True
862 raise VCSException("Bzr branch failed", p.output)
864 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
865 if p.returncode != 0:
866 raise VCSException("Bzr revert failed", p.output)
867 if not self.refreshed:
868 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("Bzr update failed", p.output)
871 self.refreshed = True
873 revargs = list(['-r', rev] if rev else [])
874 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
875 if p.returncode != 0:
876 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
879 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
880 return [tag.split(' ')[0].strip() for tag in
881 p.output.splitlines()]
884 def unescape_string(string):
885 if string[0] == '"' and string[-1] == '"':
888 return string.replace("\\'", "'")
891 def retrieve_string(app_dir, string, xmlfiles=None):
896 os.path.join(app_dir, 'res'),
897 os.path.join(app_dir, 'src', 'main', 'res'),
899 for r, d, f in os.walk(res_dir):
900 if os.path.basename(r) == 'values':
901 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
903 if not string.startswith('@string/'):
904 return unescape_string(string)
906 name = string[len('@string/'):]
908 for path in xmlfiles:
909 if not os.path.isfile(path):
911 xml = parse_xml(path)
912 element = xml.find('string[@name="' + name + '"]')
913 if element is not None and element.text is not None:
914 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
919 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
920 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
923 # Return list of existing files that will be used to find the highest vercode
924 def manifest_paths(app_dir, flavours):
926 possible_manifests = \
927 [os.path.join(app_dir, 'AndroidManifest.xml'),
928 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
929 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
930 os.path.join(app_dir, 'build.gradle')]
932 for flavour in flavours:
935 possible_manifests.append(
936 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
938 return [path for path in possible_manifests if os.path.isfile(path)]
941 # Retrieve the package name. Returns the name, or None if not found.
942 def fetch_real_name(app_dir, flavours):
943 for path in manifest_paths(app_dir, flavours):
944 if not has_extension(path, 'xml') or not os.path.isfile(path):
946 logging.debug("fetch_real_name: Checking manifest at " + path)
947 xml = parse_xml(path)
948 app = xml.find('application')
949 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
951 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
952 result = retrieve_string_singleline(app_dir, label)
954 result = result.strip()
959 def get_library_references(root_dir):
961 proppath = os.path.join(root_dir, 'project.properties')
962 if not os.path.isfile(proppath):
964 for line in file(proppath):
965 if not line.startswith('android.library.reference.'):
967 path = line.split('=')[1].strip()
968 relpath = os.path.join(root_dir, path)
969 if not os.path.isdir(relpath):
971 logging.debug("Found subproject at %s" % path)
972 libraries.append(path)
976 def ant_subprojects(root_dir):
977 subprojects = get_library_references(root_dir)
978 for subpath in subprojects:
979 subrelpath = os.path.join(root_dir, subpath)
980 for p in get_library_references(subrelpath):
981 relp = os.path.normpath(os.path.join(subpath, p))
982 if relp not in subprojects:
983 subprojects.insert(0, relp)
987 def remove_debuggable_flags(root_dir):
988 # Remove forced debuggable flags
989 logging.debug("Removing debuggable flags from %s" % root_dir)
990 for root, dirs, files in os.walk(root_dir):
991 if 'AndroidManifest.xml' in files:
992 regsub_file(r'android:debuggable="[^"]*"',
994 os.path.join(root, 'AndroidManifest.xml'))
997 # Extract some information from the AndroidManifest.xml at the given path.
998 # Returns (version, vercode, package), any or all of which might be None.
999 # All values returned are strings.
1000 def parse_androidmanifests(paths, ignoreversions=None):
1003 return (None, None, None)
1005 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1006 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1007 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1009 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1017 if not os.path.isfile(path):
1020 logging.debug("Parsing manifest at {0}".format(path))
1021 gradle = has_extension(path, 'gradle')
1027 for line in file(path):
1028 # Grab first occurence of each to avoid running into
1029 # alternative flavours and builds.
1031 matches = psearch_g(line)
1033 package = matches.group(2)
1035 matches = vnsearch_g(line)
1037 version = matches.group(2)
1039 matches = vcsearch_g(line)
1041 vercode = matches.group(1)
1043 xml = parse_xml(path)
1044 if "package" in xml.attrib:
1045 package = xml.attrib["package"].encode('utf-8')
1046 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1047 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1048 base_dir = os.path.dirname(path)
1049 version = retrieve_string_singleline(base_dir, version)
1050 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1051 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1052 if string_is_integer(a):
1055 # Remember package name, may be defined separately from version+vercode
1057 package = max_package
1059 logging.debug("..got package={0}, version={1}, vercode={2}"
1060 .format(package, version, vercode))
1062 # Always grab the package name and version name in case they are not
1063 # together with the highest version code
1064 if max_package is None and package is not None:
1065 max_package = package
1066 if max_version is None and version is not None:
1067 max_version = version
1069 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1070 if not ignoresearch or not ignoresearch(version):
1071 if version is not None:
1072 max_version = version
1073 if vercode is not None:
1074 max_vercode = vercode
1075 if package is not None:
1076 max_package = package
1078 max_version = "Ignore"
1080 if max_version is None:
1081 max_version = "Unknown"
1083 if max_package and not is_valid_package_name(max_package):
1084 raise FDroidException("Invalid package name {0}".format(max_package))
1086 return (max_version, max_vercode, max_package)
1089 def is_valid_package_name(name):
1090 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1093 class FDroidException(Exception):
1095 def __init__(self, value, detail=None):
1097 self.detail = detail
1099 def get_wikitext(self):
1100 ret = repr(self.value) + "\n"
1104 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1112 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1116 class VCSException(FDroidException):
1120 class BuildException(FDroidException):
1124 # Get the specified source library.
1125 # Returns the path to it. Normally this is the path to be used when referencing
1126 # it, which may be a subdirectory of the actual project. If you want the base
1127 # directory of the project, pass 'basepath=True'.
1128 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1129 raw=False, prepare=True, preponly=False, refresh=True):
1137 name, ref = spec.split('@')
1139 number, name = name.split(':', 1)
1141 name, subdir = name.split('/', 1)
1143 if name not in metadata.srclibs:
1144 raise VCSException('srclib ' + name + ' not found.')
1146 srclib = metadata.srclibs[name]
1148 sdir = os.path.join(srclib_dir, name)
1151 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1152 vcs.srclib = (name, number, sdir)
1154 vcs.gotorevision(ref, refresh)
1161 libdir = os.path.join(sdir, subdir)
1162 elif srclib["Subdir"]:
1163 for subdir in srclib["Subdir"]:
1164 libdir_candidate = os.path.join(sdir, subdir)
1165 if os.path.exists(libdir_candidate):
1166 libdir = libdir_candidate
1172 remove_signing_keys(sdir)
1173 remove_debuggable_flags(sdir)
1177 if srclib["Prepare"]:
1178 cmd = replace_config_vars(srclib["Prepare"], None)
1180 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1181 if p.returncode != 0:
1182 raise BuildException("Error running prepare command for srclib %s"
1188 return (name, number, libdir)
1191 # Prepare the source code for a particular build
1192 # 'vcs' - the appropriate vcs object for the application
1193 # 'app' - the application details from the metadata
1194 # 'build' - the build details from the metadata
1195 # 'build_dir' - the path to the build directory, usually
1197 # 'srclib_dir' - the path to the source libraries directory, usually
1199 # 'extlib_dir' - the path to the external libraries directory, usually
1201 # Returns the (root, srclibpaths) where:
1202 # 'root' is the root directory, which may be the same as 'build_dir' or may
1203 # be a subdirectory of it.
1204 # 'srclibpaths' is information on the srclibs being used
1205 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1207 # Optionally, the actual app source can be in a subdirectory
1209 root_dir = os.path.join(build_dir, build['subdir'])
1211 root_dir = build_dir
1213 # Get a working copy of the right revision
1214 logging.info("Getting source for revision " + build['commit'])
1215 vcs.gotorevision(build['commit'], refresh)
1217 # Initialise submodules if required
1218 if build['submodules']:
1219 logging.info("Initialising submodules")
1220 vcs.initsubmodules()
1222 # Check that a subdir (if we're using one) exists. This has to happen
1223 # after the checkout, since it might not exist elsewhere
1224 if not os.path.exists(root_dir):
1225 raise BuildException('Missing subdir ' + root_dir)
1227 # Run an init command if one is required
1229 cmd = replace_config_vars(build['init'], build)
1230 logging.info("Running 'init' commands in %s" % root_dir)
1232 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1233 if p.returncode != 0:
1234 raise BuildException("Error running init command for %s:%s" %
1235 (app['id'], build['version']), p.output)
1237 # Apply patches if any
1239 logging.info("Applying patches")
1240 for patch in build['patch']:
1241 patch = patch.strip()
1242 logging.info("Applying " + patch)
1243 patch_path = os.path.join('metadata', app['id'], patch)
1244 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1245 if p.returncode != 0:
1246 raise BuildException("Failed to apply patch %s" % patch_path)
1248 # Get required source libraries
1250 if build['srclibs']:
1251 logging.info("Collecting source libraries")
1252 for lib in build['srclibs']:
1253 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1255 for name, number, libpath in srclibpaths:
1256 place_srclib(root_dir, int(number) if number else None, libpath)
1258 basesrclib = vcs.getsrclib()
1259 # If one was used for the main source, add that too.
1261 srclibpaths.append(basesrclib)
1263 # Update the local.properties file
1264 localprops = [os.path.join(build_dir, 'local.properties')]
1266 localprops += [os.path.join(root_dir, 'local.properties')]
1267 for path in localprops:
1269 if os.path.isfile(path):
1270 logging.info("Updating local.properties file at %s" % path)
1271 with open(path, 'r') as f:
1275 logging.info("Creating local.properties file at %s" % path)
1276 # Fix old-fashioned 'sdk-location' by copying
1277 # from sdk.dir, if necessary
1278 if build['oldsdkloc']:
1279 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1280 re.S | re.M).group(1)
1281 props += "sdk-location=%s\n" % sdkloc
1283 props += "sdk.dir=%s\n" % config['sdk_path']
1284 props += "sdk-location=%s\n" % config['sdk_path']
1285 if build['ndk_path']:
1287 props += "ndk.dir=%s\n" % build['ndk_path']
1288 props += "ndk-location=%s\n" % build['ndk_path']
1289 # Add java.encoding if necessary
1290 if build['encoding']:
1291 props += "java.encoding=%s\n" % build['encoding']
1292 with open(path, 'w') as f:
1296 if build['type'] == 'gradle':
1297 flavours = build['gradle']
1299 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1300 gradlepluginver = None
1302 gradle_dirs = [root_dir]
1304 # Parent dir build.gradle
1305 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1306 if parent_dir.startswith(build_dir):
1307 gradle_dirs.append(parent_dir)
1309 for dir_path in gradle_dirs:
1312 if not os.path.isdir(dir_path):
1314 for filename in os.listdir(dir_path):
1315 if not filename.endswith('.gradle'):
1317 path = os.path.join(dir_path, filename)
1318 if not os.path.isfile(path):
1320 for line in file(path):
1321 match = version_regex.match(line)
1323 gradlepluginver = match.group(1)
1327 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1329 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1330 build['gradlepluginver'] = LooseVersion('0.11')
1333 n = build["target"].split('-')[1]
1334 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1335 r'compileSdkVersion %s' % n,
1336 os.path.join(root_dir, 'build.gradle'))
1338 # Remove forced debuggable flags
1339 remove_debuggable_flags(root_dir)
1341 # Insert version code and number into the manifest if necessary
1342 if build['forceversion']:
1343 logging.info("Changing the version name")
1344 for path in manifest_paths(root_dir, flavours):
1345 if not os.path.isfile(path):
1347 if has_extension(path, 'xml'):
1348 regsub_file(r'android:versionName="[^"]*"',
1349 r'android:versionName="%s"' % build['version'],
1351 elif has_extension(path, 'gradle'):
1352 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1353 r"""\1versionName '%s'""" % build['version'],
1356 if build['forcevercode']:
1357 logging.info("Changing the version code")
1358 for path in manifest_paths(root_dir, flavours):
1359 if not os.path.isfile(path):
1361 if has_extension(path, 'xml'):
1362 regsub_file(r'android:versionCode="[^"]*"',
1363 r'android:versionCode="%s"' % build['vercode'],
1365 elif has_extension(path, 'gradle'):
1366 regsub_file(r'versionCode[ =]+[0-9]+',
1367 r'versionCode %s' % build['vercode'],
1370 # Delete unwanted files
1372 logging.info("Removing specified files")
1373 for part in getpaths(build_dir, build, 'rm'):
1374 dest = os.path.join(build_dir, part)
1375 logging.info("Removing {0}".format(part))
1376 if os.path.lexists(dest):
1377 if os.path.islink(dest):
1378 FDroidPopen(['unlink', dest], output=False)
1380 FDroidPopen(['rm', '-rf', dest], output=False)
1382 logging.info("...but it didn't exist")
1384 remove_signing_keys(build_dir)
1386 # Add required external libraries
1387 if build['extlibs']:
1388 logging.info("Collecting prebuilt libraries")
1389 libsdir = os.path.join(root_dir, 'libs')
1390 if not os.path.exists(libsdir):
1392 for lib in build['extlibs']:
1394 logging.info("...installing extlib {0}".format(lib))
1395 libf = os.path.basename(lib)
1396 libsrc = os.path.join(extlib_dir, lib)
1397 if not os.path.exists(libsrc):
1398 raise BuildException("Missing extlib file {0}".format(libsrc))
1399 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1401 # Run a pre-build command if one is required
1402 if build['prebuild']:
1403 logging.info("Running 'prebuild' commands in %s" % root_dir)
1405 cmd = replace_config_vars(build['prebuild'], build)
1407 # Substitute source library paths into prebuild commands
1408 for name, number, libpath in srclibpaths:
1409 libpath = os.path.relpath(libpath, root_dir)
1410 cmd = cmd.replace('$$' + name + '$$', libpath)
1412 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1413 if p.returncode != 0:
1414 raise BuildException("Error running prebuild command for %s:%s" %
1415 (app['id'], build['version']), p.output)
1417 # Generate (or update) the ant build file, build.xml...
1418 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1419 parms = ['android', 'update', 'lib-project']
1420 lparms = ['android', 'update', 'project']
1423 parms += ['-t', build['target']]
1424 lparms += ['-t', build['target']]
1425 if build['update'] == ['auto']:
1426 update_dirs = ant_subprojects(root_dir) + ['.']
1428 update_dirs = build['update']
1430 for d in update_dirs:
1431 subdir = os.path.join(root_dir, d)
1433 logging.debug("Updating main project")
1434 cmd = parms + ['-p', d]
1436 logging.debug("Updating subproject %s" % d)
1437 cmd = lparms + ['-p', d]
1438 p = SdkToolsPopen(cmd, cwd=root_dir)
1439 # Check to see whether an error was returned without a proper exit
1440 # code (this is the case for the 'no target set or target invalid'
1442 if p.returncode != 0 or p.output.startswith("Error: "):
1443 raise BuildException("Failed to update project at %s" % d, p.output)
1444 # Clean update dirs via ant
1446 logging.info("Cleaning subproject %s" % d)
1447 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1449 return (root_dir, srclibpaths)
1452 # Split and extend via globbing the paths from a field
1453 def getpaths(build_dir, build, field):
1455 for p in build[field]:
1457 full_path = os.path.join(build_dir, p)
1458 full_path = os.path.normpath(full_path)
1459 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1464 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1470 self.path = os.path.join('stats', 'known_apks.txt')
1472 if os.path.isfile(self.path):
1473 for line in file(self.path):
1474 t = line.rstrip().split(' ')
1476 self.apks[t[0]] = (t[1], None)
1478 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1479 self.changed = False
1481 def writeifchanged(self):
1482 if not self.changed:
1485 if not os.path.exists('stats'):
1489 for apk, app in self.apks.iteritems():
1491 line = apk + ' ' + appid
1493 line += ' ' + time.strftime('%Y-%m-%d', added)
1496 with open(self.path, 'w') as f:
1497 for line in sorted(lst, key=natural_key):
1498 f.write(line + '\n')
1500 # Record an apk (if it's new, otherwise does nothing)
1501 # Returns the date it was added.
1502 def recordapk(self, apk, app):
1503 if apk not in self.apks:
1504 self.apks[apk] = (app, time.gmtime(time.time()))
1506 _, added = self.apks[apk]
1509 # Look up information - given the 'apkname', returns (app id, date added/None).
1510 # Or returns None for an unknown apk.
1511 def getapp(self, apkname):
1512 if apkname in self.apks:
1513 return self.apks[apkname]
1516 # Get the most recent 'num' apps added to the repo, as a list of package ids
1517 # with the most recent first.
1518 def getlatest(self, num):
1520 for apk, app in self.apks.iteritems():
1524 if apps[appid] > added:
1528 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1529 lst = [app for app, _ in sortedapps]
1534 def isApkDebuggable(apkfile, config):
1535 """Returns True if the given apk file is debuggable
1537 :param apkfile: full path to the apk to check"""
1539 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1541 if p.returncode != 0:
1542 logging.critical("Failed to get apk manifest information")
1544 for line in p.output.splitlines():
1545 if 'android:debuggable' in line and not line.endswith('0x0'):
1550 class AsynchronousFileReader(threading.Thread):
1553 Helper class to implement asynchronous reading of a file
1554 in a separate thread. Pushes read lines on a queue to
1555 be consumed in another thread.
1558 def __init__(self, fd, queue):
1559 assert isinstance(queue, Queue.Queue)
1560 assert callable(fd.readline)
1561 threading.Thread.__init__(self)
1566 '''The body of the tread: read lines and put them on the queue.'''
1567 for line in iter(self._fd.readline, ''):
1568 self._queue.put(line)
1571 '''Check whether there is no more content to expect.'''
1572 return not self.is_alive() and self._queue.empty()
1580 def SdkToolsPopen(commands, cwd=None, output=True):
1582 if cmd not in config:
1583 config[cmd] = find_sdk_tools_cmd(commands[0])
1584 return FDroidPopen([config[cmd]] + commands[1:],
1585 cwd=cwd, output=output)
1588 def FDroidPopen(commands, cwd=None, output=True):
1590 Run a command and capture the possibly huge output.
1592 :param commands: command and argument list like in subprocess.Popen
1593 :param cwd: optionally specifies a working directory
1594 :returns: A PopenResult.
1600 cwd = os.path.normpath(cwd)
1601 logging.debug("Directory: %s" % cwd)
1602 logging.debug("> %s" % ' '.join(commands))
1604 result = PopenResult()
1607 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1608 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1610 raise BuildException("OSError while trying to execute " +
1611 ' '.join(commands) + ': ' + str(e))
1613 stdout_queue = Queue.Queue()
1614 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1615 stdout_reader.start()
1617 # Check the queue for output (until there is no more to get)
1618 while not stdout_reader.eof():
1619 while not stdout_queue.empty():
1620 line = stdout_queue.get()
1621 if output and options.verbose:
1622 # Output directly to console
1623 sys.stderr.write(line)
1625 result.output += line
1629 result.returncode = p.wait()
1633 def remove_signing_keys(build_dir):
1634 comment = re.compile(r'[ ]*//')
1635 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1637 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1638 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1639 re.compile(r'.*variant\.outputFile = .*'),
1640 re.compile(r'.*output\.outputFile = .*'),
1641 re.compile(r'.*\.readLine\(.*'),
1643 for root, dirs, files in os.walk(build_dir):
1644 if 'build.gradle' in files:
1645 path = os.path.join(root, 'build.gradle')
1647 with open(path, "r") as o:
1648 lines = o.readlines()
1654 with open(path, "w") as o:
1655 while i < len(lines):
1658 while line.endswith('\\\n'):
1659 line = line.rstrip('\\\n') + lines[i]
1662 if comment.match(line):
1666 opened += line.count('{')
1667 opened -= line.count('}')
1670 if signing_configs.match(line):
1675 if any(s.match(line) for s in line_matches):
1683 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1686 'project.properties',
1688 'default.properties',
1689 'ant.properties', ]:
1690 if propfile in files:
1691 path = os.path.join(root, propfile)
1693 with open(path, "r") as o:
1694 lines = o.readlines()
1698 with open(path, "w") as o:
1700 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1707 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1710 def reset_env_path():
1711 global env, orig_path
1712 env['PATH'] = orig_path
1715 def add_to_env_path(path):
1717 paths = env['PATH'].split(os.pathsep)
1721 env['PATH'] = os.pathsep.join(paths)
1724 def replace_config_vars(cmd, build):
1726 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1727 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1728 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1729 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1730 if build is not None:
1731 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1732 cmd = cmd.replace('$$VERSION$$', build['version'])
1733 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1737 def place_srclib(root_dir, number, libpath):
1740 relpath = os.path.relpath(libpath, root_dir)
1741 proppath = os.path.join(root_dir, 'project.properties')
1744 if os.path.isfile(proppath):
1745 with open(proppath, "r") as o:
1746 lines = o.readlines()
1748 with open(proppath, "w") as o:
1751 if line.startswith('android.library.reference.%d=' % number):
1752 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1757 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1760 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1761 """Verify that two apks are the same
1763 One of the inputs is signed, the other is unsigned. The signature metadata
1764 is transferred from the signed to the unsigned apk, and then jarsigner is
1765 used to verify that the signature from the signed apk is also varlid for
1767 :param signed_apk: Path to a signed apk file
1768 :param unsigned_apk: Path to an unsigned apk file expected to match it
1769 :param tmp_dir: Path to directory for temporary files
1770 :returns: None if the verification is successful, otherwise a string
1771 describing what went wrong.
1773 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1774 with ZipFile(signed_apk) as signed_apk_as_zip:
1775 meta_inf_files = ['META-INF/MANIFEST.MF']
1776 for f in signed_apk_as_zip.namelist():
1777 if sigfile.match(f):
1778 meta_inf_files.append(f)
1779 if len(meta_inf_files) < 3:
1780 return "Signature files missing from {0}".format(signed_apk)
1781 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1782 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1783 for meta_inf_file in meta_inf_files:
1784 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1786 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1787 logging.info("...NOT verified - {0}".format(signed_apk))
1788 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1789 logging.info("...successfully verified")
1793 def compare_apks(apk1, apk2, tmp_dir):
1796 Returns None if the apk content is the same (apart from the signing key),
1797 otherwise a string describing what's different, or what went wrong when
1798 trying to do the comparison.
1801 badchars = re.compile('''[/ :;'"]''')
1802 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1803 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1804 for d in [apk1dir, apk2dir]:
1805 if os.path.exists(d):
1808 os.mkdir(os.path.join(d, 'jar-xf'))
1810 if subprocess.call(['jar', 'xf',
1811 os.path.abspath(apk1)],
1812 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1813 return("Failed to unpack " + apk1)
1814 if subprocess.call(['jar', 'xf',
1815 os.path.abspath(apk2)],
1816 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1817 return("Failed to unpack " + apk2)
1819 # try to find apktool in the path, if it hasn't been manually configed
1820 if 'apktool' not in config:
1821 tmp = find_command('apktool')
1823 config['apktool'] = tmp
1824 if 'apktool' in config:
1825 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1827 return("Failed to unpack " + apk1)
1828 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1830 return("Failed to unpack " + apk2)
1832 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1833 lines = p.output.splitlines()
1834 if len(lines) != 1 or 'META-INF' not in lines[0]:
1835 meld = find_command('meld')
1836 if meld is not None:
1837 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1838 return("Unexpected diff output - " + p.output)
1840 # since everything verifies, delete the comparison to keep cruft down
1841 shutil.rmtree(apk1dir)
1842 shutil.rmtree(apk2dir)
1844 # If we get here, it seems like they're the same!
1848 def find_command(command):
1849 '''find the full path of a command, or None if it can't be found in the PATH'''
1852 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1854 fpath, fname = os.path.split(command)
1859 for path in os.environ["PATH"].split(os.pathsep):
1860 path = path.strip('"')
1861 exe_file = os.path.join(path, command)
1862 if is_exe(exe_file):
1869 '''generate a random password for when generating keys'''
1870 h = hashlib.sha256()
1871 h.update(os.urandom(16)) # salt
1872 h.update(bytes(socket.getfqdn()))
1873 return h.digest().encode('base64').strip()
1876 def genkeystore(localconfig):
1877 '''Generate a new key with random passwords and add it to new keystore'''
1878 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1879 keystoredir = os.path.dirname(localconfig['keystore'])
1880 if keystoredir is None or keystoredir == '':
1881 keystoredir = os.path.join(os.getcwd(), keystoredir)
1882 if not os.path.exists(keystoredir):
1883 os.makedirs(keystoredir, mode=0o700)
1885 write_password_file("keystorepass", localconfig['keystorepass'])
1886 write_password_file("keypass", localconfig['keypass'])
1887 p = FDroidPopen(['keytool', '-genkey',
1888 '-keystore', localconfig['keystore'],
1889 '-alias', localconfig['repo_keyalias'],
1890 '-keyalg', 'RSA', '-keysize', '4096',
1891 '-sigalg', 'SHA256withRSA',
1892 '-validity', '10000',
1893 '-storepass:file', config['keystorepassfile'],
1894 '-keypass:file', config['keypassfile'],
1895 '-dname', localconfig['keydname']])
1896 # TODO keypass should be sent via stdin
1897 if p.returncode != 0:
1898 raise BuildException("Failed to generate key", p.output)
1899 os.chmod(localconfig['keystore'], 0o0600)
1900 # now show the lovely key that was just generated
1901 p = FDroidPopen(['keytool', '-list', '-v',
1902 '-keystore', localconfig['keystore'],
1903 '-alias', localconfig['repo_keyalias'],
1904 '-storepass:file', config['keystorepassfile']])
1905 logging.info(p.output.strip() + '\n\n')
1908 def write_to_config(thisconfig, key, value=None):
1909 '''write a key/value to the local config.py'''
1911 origkey = key + '_orig'
1912 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1913 with open('config.py', 'r') as f:
1915 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1916 repl = '\n' + key + ' = "' + value + '"'
1917 data = re.sub(pattern, repl, data)
1918 # if this key is not in the file, append it
1919 if not re.match('\s*' + key + '\s*=\s*"', data):
1921 # make sure the file ends with a carraige return
1922 if not re.match('\n$', data):
1924 with open('config.py', 'w') as f:
1928 def parse_xml(path):
1929 return XMLElementTree.parse(path).getroot()
1932 def string_is_integer(string):
1940 def get_per_app_repos():
1941 '''per-app repos are dirs named with the packageName of a single app'''
1943 # Android packageNames are Java packages, they may contain uppercase or
1944 # lowercase letters ('A' through 'Z'), numbers, and underscores
1945 # ('_'). However, individual package name parts may only start with
1946 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1947 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1950 for root, dirs, files in os.walk(os.getcwd()):
1952 print 'checking', root, 'for', d
1953 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1954 # standard parts of an fdroid repo, so never packageNames
1957 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):