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/')
654 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
655 if p.returncode != 0:
656 raise VCSException("Git submodule sync failed", p.output)
657 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
658 if p.returncode != 0:
659 raise VCSException("Git submodule update failed", p.output)
663 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
664 return p.output.splitlines()
666 def latesttags(self, tags, number):
671 ['git', 'show', '--format=format:%ct', '-s', tag],
672 cwd=self.local, output=False)
673 # Timestamp is on the last line. For a normal tag, it's the only
674 # line, but for annotated tags, the rest of the info precedes it.
675 ts = int(p.output.splitlines()[-1])
678 for _, t in sorted(tl)[-number:]:
683 class vcs_gitsvn(vcs):
688 # If the local directory exists, but is somehow not a git repository, git
689 # will traverse up the directory tree until it finds one that is (i.e.
690 # fdroidserver) and then we'll proceed to destory it! This is called as
693 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
694 result = p.output.rstrip()
695 if not result.endswith(self.local):
696 raise VCSException('Repository mismatch')
698 def gotorevisionx(self, rev):
699 if not os.path.exists(self.local):
701 gitsvn_args = ['git', 'svn', 'clone']
702 if ';' in self.remote:
703 remote_split = self.remote.split(';')
704 for i in remote_split[1:]:
705 if i.startswith('trunk='):
706 gitsvn_args.extend(['-T', i[6:]])
707 elif i.startswith('tags='):
708 gitsvn_args.extend(['-t', i[5:]])
709 elif i.startswith('branches='):
710 gitsvn_args.extend(['-b', i[9:]])
711 gitsvn_args.extend([remote_split[0], self.local])
712 p = FDroidPopen(gitsvn_args, output=False)
713 if p.returncode != 0:
714 self.clone_failed = True
715 raise VCSException("Git svn clone failed", p.output)
717 gitsvn_args.extend([self.remote, self.local])
718 p = FDroidPopen(gitsvn_args, output=False)
719 if p.returncode != 0:
720 self.clone_failed = True
721 raise VCSException("Git svn clone failed", p.output)
725 # Discard any working tree changes
726 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
727 if p.returncode != 0:
728 raise VCSException("Git reset failed", p.output)
729 # Remove untracked files now, in case they're tracked in the target
730 # revision (it happens!)
731 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
732 if p.returncode != 0:
733 raise VCSException("Git clean failed", p.output)
734 if not self.refreshed:
735 # Get new commits, branches and tags from repo
736 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
737 if p.returncode != 0:
738 raise VCSException("Git svn fetch failed")
739 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
740 if p.returncode != 0:
741 raise VCSException("Git svn rebase failed", p.output)
742 self.refreshed = True
744 rev = rev or 'master'
746 nospaces_rev = rev.replace(' ', '%20')
747 # Try finding a svn tag
748 for treeish in ['origin/', '']:
749 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
750 if p.returncode == 0:
752 if p.returncode != 0:
753 # No tag found, normal svn rev translation
754 # Translate svn rev into git format
755 rev_split = rev.split('/')
758 for treeish in ['origin/', '']:
759 if len(rev_split) > 1:
760 treeish += rev_split[0]
761 svn_rev = rev_split[1]
764 # if no branch is specified, then assume trunk (i.e. 'master' branch):
768 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
770 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
771 git_rev = p.output.rstrip()
773 if p.returncode == 0 and git_rev:
776 if p.returncode != 0 or not git_rev:
777 # Try a plain git checkout as a last resort
778 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
779 if p.returncode != 0:
780 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
782 # Check out the git rev equivalent to the svn rev
783 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
784 if p.returncode != 0:
785 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
787 # Get rid of any uncontrolled files left behind
788 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
789 if p.returncode != 0:
790 raise VCSException("Git clean failed", p.output)
794 for treeish in ['origin/', '']:
795 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
801 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
802 if p.returncode != 0:
804 return p.output.strip()
812 def gotorevisionx(self, rev):
813 if not os.path.exists(self.local):
814 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
815 if p.returncode != 0:
816 self.clone_failed = True
817 raise VCSException("Hg clone failed", p.output)
819 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
820 if p.returncode != 0:
821 raise VCSException("Hg status failed", p.output)
822 for line in p.output.splitlines():
823 if not line.startswith('? '):
824 raise VCSException("Unexpected output from hg status -uS: " + line)
825 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
826 if not self.refreshed:
827 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
828 if p.returncode != 0:
829 raise VCSException("Hg pull failed", p.output)
830 self.refreshed = True
832 rev = rev or 'default'
835 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
836 if p.returncode != 0:
837 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
838 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
839 # Also delete untracked files, we have to enable purge extension for that:
840 if "'purge' is provided by the following extension" in p.output:
841 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
842 myfile.write("\n[extensions]\nhgext.purge=\n")
843 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
844 if p.returncode != 0:
845 raise VCSException("HG purge failed", p.output)
846 elif p.returncode != 0:
847 raise VCSException("HG purge failed", p.output)
850 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
851 return p.output.splitlines()[1:]
859 def gotorevisionx(self, rev):
860 if not os.path.exists(self.local):
861 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
862 if p.returncode != 0:
863 self.clone_failed = True
864 raise VCSException("Bzr branch failed", p.output)
866 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
867 if p.returncode != 0:
868 raise VCSException("Bzr revert failed", p.output)
869 if not self.refreshed:
870 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Bzr update failed", p.output)
873 self.refreshed = True
875 revargs = list(['-r', rev] if rev else [])
876 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
877 if p.returncode != 0:
878 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
881 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
882 return [tag.split(' ')[0].strip() for tag in
883 p.output.splitlines()]
886 def unescape_string(string):
889 if string[0] == '"' and string[-1] == '"':
892 return string.replace("\\'", "'")
895 def retrieve_string(app_dir, string, xmlfiles=None):
897 if not string.startswith('@string/'):
898 return unescape_string(string)
903 os.path.join(app_dir, 'res'),
904 os.path.join(app_dir, 'src', 'main', 'res'),
906 for r, d, f in os.walk(res_dir):
907 if os.path.basename(r) == 'values':
908 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
910 name = string[len('@string/'):]
912 def element_content(element):
913 if element.text is None:
915 return XMLElementTree.tostring(element, encoding='utf-8', method='text')
917 for path in xmlfiles:
918 if not os.path.isfile(path):
920 xml = parse_xml(path)
921 element = xml.find('string[@name="' + name + '"]')
922 if element is not None:
923 content = element_content(element)
924 return retrieve_string(app_dir, content, xmlfiles)
929 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
930 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
933 # Return list of existing files that will be used to find the highest vercode
934 def manifest_paths(app_dir, flavours):
936 possible_manifests = \
937 [os.path.join(app_dir, 'AndroidManifest.xml'),
938 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
939 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
940 os.path.join(app_dir, 'build.gradle')]
942 for flavour in flavours:
945 possible_manifests.append(
946 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
948 return [path for path in possible_manifests if os.path.isfile(path)]
951 # Retrieve the package name. Returns the name, or None if not found.
952 def fetch_real_name(app_dir, flavours):
953 for path in manifest_paths(app_dir, flavours):
954 if not has_extension(path, 'xml') or not os.path.isfile(path):
956 logging.debug("fetch_real_name: Checking manifest at " + path)
957 xml = parse_xml(path)
958 app = xml.find('application')
959 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
961 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
962 result = retrieve_string_singleline(app_dir, label)
964 result = result.strip()
969 def get_library_references(root_dir):
971 proppath = os.path.join(root_dir, 'project.properties')
972 if not os.path.isfile(proppath):
974 for line in file(proppath):
975 if not line.startswith('android.library.reference.'):
977 path = line.split('=')[1].strip()
978 relpath = os.path.join(root_dir, path)
979 if not os.path.isdir(relpath):
981 logging.debug("Found subproject at %s" % path)
982 libraries.append(path)
986 def ant_subprojects(root_dir):
987 subprojects = get_library_references(root_dir)
988 for subpath in subprojects:
989 subrelpath = os.path.join(root_dir, subpath)
990 for p in get_library_references(subrelpath):
991 relp = os.path.normpath(os.path.join(subpath, p))
992 if relp not in subprojects:
993 subprojects.insert(0, relp)
997 def remove_debuggable_flags(root_dir):
998 # Remove forced debuggable flags
999 logging.debug("Removing debuggable flags from %s" % root_dir)
1000 for root, dirs, files in os.walk(root_dir):
1001 if 'AndroidManifest.xml' in files:
1002 regsub_file(r'android:debuggable="[^"]*"',
1004 os.path.join(root, 'AndroidManifest.xml'))
1007 # Extract some information from the AndroidManifest.xml at the given path.
1008 # Returns (version, vercode, package), any or all of which might be None.
1009 # All values returned are strings.
1010 def parse_androidmanifests(paths, ignoreversions=None):
1013 return (None, None, None)
1015 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1016 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1017 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1019 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1027 if not os.path.isfile(path):
1030 logging.debug("Parsing manifest at {0}".format(path))
1031 gradle = has_extension(path, 'gradle')
1037 for line in file(path):
1038 if gradle_comment.match(line):
1040 # Grab first occurence of each to avoid running into
1041 # alternative flavours and builds.
1043 matches = psearch_g(line)
1045 package = matches.group(2)
1047 matches = vnsearch_g(line)
1049 version = matches.group(2)
1051 matches = vcsearch_g(line)
1053 vercode = matches.group(1)
1055 xml = parse_xml(path)
1056 if "package" in xml.attrib:
1057 package = xml.attrib["package"].encode('utf-8')
1058 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1059 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1060 base_dir = os.path.dirname(path)
1061 version = retrieve_string_singleline(base_dir, version)
1062 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1063 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1064 if string_is_integer(a):
1067 # Remember package name, may be defined separately from version+vercode
1069 package = max_package
1071 logging.debug("..got package={0}, version={1}, vercode={2}"
1072 .format(package, version, vercode))
1074 # Always grab the package name and version name in case they are not
1075 # together with the highest version code
1076 if max_package is None and package is not None:
1077 max_package = package
1078 if max_version is None and version is not None:
1079 max_version = version
1081 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1082 if not ignoresearch or not ignoresearch(version):
1083 if version is not None:
1084 max_version = version
1085 if vercode is not None:
1086 max_vercode = vercode
1087 if package is not None:
1088 max_package = package
1090 max_version = "Ignore"
1092 if max_version is None:
1093 max_version = "Unknown"
1095 if max_package and not is_valid_package_name(max_package):
1096 raise FDroidException("Invalid package name {0}".format(max_package))
1098 return (max_version, max_vercode, max_package)
1101 def is_valid_package_name(name):
1102 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1105 class FDroidException(Exception):
1107 def __init__(self, value, detail=None):
1109 self.detail = detail
1111 def shortened_detail(self):
1112 if len(self.detail) < 16000:
1114 return '[...]\n' + self.detail[-16000:]
1116 def get_wikitext(self):
1117 ret = repr(self.value) + "\n"
1120 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1126 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1130 class VCSException(FDroidException):
1134 class BuildException(FDroidException):
1138 # Get the specified source library.
1139 # Returns the path to it. Normally this is the path to be used when referencing
1140 # it, which may be a subdirectory of the actual project. If you want the base
1141 # directory of the project, pass 'basepath=True'.
1142 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1143 raw=False, prepare=True, preponly=False, refresh=True):
1151 name, ref = spec.split('@')
1153 number, name = name.split(':', 1)
1155 name, subdir = name.split('/', 1)
1157 if name not in metadata.srclibs:
1158 raise VCSException('srclib ' + name + ' not found.')
1160 srclib = metadata.srclibs[name]
1162 sdir = os.path.join(srclib_dir, name)
1165 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1166 vcs.srclib = (name, number, sdir)
1168 vcs.gotorevision(ref, refresh)
1175 libdir = os.path.join(sdir, subdir)
1176 elif srclib["Subdir"]:
1177 for subdir in srclib["Subdir"]:
1178 libdir_candidate = os.path.join(sdir, subdir)
1179 if os.path.exists(libdir_candidate):
1180 libdir = libdir_candidate
1186 remove_signing_keys(sdir)
1187 remove_debuggable_flags(sdir)
1191 if srclib["Prepare"]:
1192 cmd = replace_config_vars(srclib["Prepare"], None)
1194 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1195 if p.returncode != 0:
1196 raise BuildException("Error running prepare command for srclib %s"
1202 return (name, number, libdir)
1205 # Prepare the source code for a particular build
1206 # 'vcs' - the appropriate vcs object for the application
1207 # 'app' - the application details from the metadata
1208 # 'build' - the build details from the metadata
1209 # 'build_dir' - the path to the build directory, usually
1211 # 'srclib_dir' - the path to the source libraries directory, usually
1213 # 'extlib_dir' - the path to the external libraries directory, usually
1215 # Returns the (root, srclibpaths) where:
1216 # 'root' is the root directory, which may be the same as 'build_dir' or may
1217 # be a subdirectory of it.
1218 # 'srclibpaths' is information on the srclibs being used
1219 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1221 # Optionally, the actual app source can be in a subdirectory
1223 root_dir = os.path.join(build_dir, build['subdir'])
1225 root_dir = build_dir
1227 # Get a working copy of the right revision
1228 logging.info("Getting source for revision " + build['commit'])
1229 vcs.gotorevision(build['commit'], refresh)
1231 # Initialise submodules if required
1232 if build['submodules']:
1233 logging.info("Initialising submodules")
1234 vcs.initsubmodules()
1236 # Check that a subdir (if we're using one) exists. This has to happen
1237 # after the checkout, since it might not exist elsewhere
1238 if not os.path.exists(root_dir):
1239 raise BuildException('Missing subdir ' + root_dir)
1241 # Run an init command if one is required
1243 cmd = replace_config_vars(build['init'], build)
1244 logging.info("Running 'init' commands in %s" % root_dir)
1246 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1247 if p.returncode != 0:
1248 raise BuildException("Error running init command for %s:%s" %
1249 (app['id'], build['version']), p.output)
1251 # Apply patches if any
1253 logging.info("Applying patches")
1254 for patch in build['patch']:
1255 patch = patch.strip()
1256 logging.info("Applying " + patch)
1257 patch_path = os.path.join('metadata', app['id'], patch)
1258 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1259 if p.returncode != 0:
1260 raise BuildException("Failed to apply patch %s" % patch_path)
1262 # Get required source libraries
1264 if build['srclibs']:
1265 logging.info("Collecting source libraries")
1266 for lib in build['srclibs']:
1267 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1269 for name, number, libpath in srclibpaths:
1270 place_srclib(root_dir, int(number) if number else None, libpath)
1272 basesrclib = vcs.getsrclib()
1273 # If one was used for the main source, add that too.
1275 srclibpaths.append(basesrclib)
1277 # Update the local.properties file
1278 localprops = [os.path.join(build_dir, 'local.properties')]
1280 localprops += [os.path.join(root_dir, 'local.properties')]
1281 for path in localprops:
1283 if os.path.isfile(path):
1284 logging.info("Updating local.properties file at %s" % path)
1285 with open(path, 'r') as f:
1289 logging.info("Creating local.properties file at %s" % path)
1290 # Fix old-fashioned 'sdk-location' by copying
1291 # from sdk.dir, if necessary
1292 if build['oldsdkloc']:
1293 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1294 re.S | re.M).group(1)
1295 props += "sdk-location=%s\n" % sdkloc
1297 props += "sdk.dir=%s\n" % config['sdk_path']
1298 props += "sdk-location=%s\n" % config['sdk_path']
1299 if build['ndk_path']:
1301 props += "ndk.dir=%s\n" % build['ndk_path']
1302 props += "ndk-location=%s\n" % build['ndk_path']
1303 # Add java.encoding if necessary
1304 if build['encoding']:
1305 props += "java.encoding=%s\n" % build['encoding']
1306 with open(path, 'w') as f:
1310 if build['type'] == 'gradle':
1311 flavours = build['gradle']
1313 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1314 gradlepluginver = None
1316 gradle_dirs = [root_dir]
1318 # Parent dir build.gradle
1319 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1320 if parent_dir.startswith(build_dir):
1321 gradle_dirs.append(parent_dir)
1323 for dir_path in gradle_dirs:
1326 if not os.path.isdir(dir_path):
1328 for filename in os.listdir(dir_path):
1329 if not filename.endswith('.gradle'):
1331 path = os.path.join(dir_path, filename)
1332 if not os.path.isfile(path):
1334 for line in file(path):
1335 match = version_regex.match(line)
1337 gradlepluginver = match.group(1)
1341 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1343 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1344 build['gradlepluginver'] = LooseVersion('0.11')
1347 n = build["target"].split('-')[1]
1348 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1349 r'compileSdkVersion %s' % n,
1350 os.path.join(root_dir, 'build.gradle'))
1352 # Remove forced debuggable flags
1353 remove_debuggable_flags(root_dir)
1355 # Insert version code and number into the manifest if necessary
1356 if build['forceversion']:
1357 logging.info("Changing the version name")
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:versionName="[^"]*"',
1363 r'android:versionName="%s"' % build['version'],
1365 elif has_extension(path, 'gradle'):
1366 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1367 r"""\1versionName '%s'""" % build['version'],
1370 if build['forcevercode']:
1371 logging.info("Changing the version code")
1372 for path in manifest_paths(root_dir, flavours):
1373 if not os.path.isfile(path):
1375 if has_extension(path, 'xml'):
1376 regsub_file(r'android:versionCode="[^"]*"',
1377 r'android:versionCode="%s"' % build['vercode'],
1379 elif has_extension(path, 'gradle'):
1380 regsub_file(r'versionCode[ =]+[0-9]+',
1381 r'versionCode %s' % build['vercode'],
1384 # Delete unwanted files
1386 logging.info("Removing specified files")
1387 for part in getpaths(build_dir, build['rm']):
1388 dest = os.path.join(build_dir, part)
1389 logging.info("Removing {0}".format(part))
1390 if os.path.lexists(dest):
1391 if os.path.islink(dest):
1392 FDroidPopen(['unlink', dest], output=False)
1394 FDroidPopen(['rm', '-rf', dest], output=False)
1396 logging.info("...but it didn't exist")
1398 remove_signing_keys(build_dir)
1400 # Add required external libraries
1401 if build['extlibs']:
1402 logging.info("Collecting prebuilt libraries")
1403 libsdir = os.path.join(root_dir, 'libs')
1404 if not os.path.exists(libsdir):
1406 for lib in build['extlibs']:
1408 logging.info("...installing extlib {0}".format(lib))
1409 libf = os.path.basename(lib)
1410 libsrc = os.path.join(extlib_dir, lib)
1411 if not os.path.exists(libsrc):
1412 raise BuildException("Missing extlib file {0}".format(libsrc))
1413 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1415 # Run a pre-build command if one is required
1416 if build['prebuild']:
1417 logging.info("Running 'prebuild' commands in %s" % root_dir)
1419 cmd = replace_config_vars(build['prebuild'], build)
1421 # Substitute source library paths into prebuild commands
1422 for name, number, libpath in srclibpaths:
1423 libpath = os.path.relpath(libpath, root_dir)
1424 cmd = cmd.replace('$$' + name + '$$', libpath)
1426 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1427 if p.returncode != 0:
1428 raise BuildException("Error running prebuild command for %s:%s" %
1429 (app['id'], build['version']), p.output)
1431 # Generate (or update) the ant build file, build.xml...
1432 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1433 parms = ['android', 'update', 'lib-project']
1434 lparms = ['android', 'update', 'project']
1437 parms += ['-t', build['target']]
1438 lparms += ['-t', build['target']]
1439 if build['update'] == ['auto']:
1440 update_dirs = ant_subprojects(root_dir) + ['.']
1442 update_dirs = build['update']
1444 for d in update_dirs:
1445 subdir = os.path.join(root_dir, d)
1447 logging.debug("Updating main project")
1448 cmd = parms + ['-p', d]
1450 logging.debug("Updating subproject %s" % d)
1451 cmd = lparms + ['-p', d]
1452 p = SdkToolsPopen(cmd, cwd=root_dir)
1453 # Check to see whether an error was returned without a proper exit
1454 # code (this is the case for the 'no target set or target invalid'
1456 if p.returncode != 0 or p.output.startswith("Error: "):
1457 raise BuildException("Failed to update project at %s" % d, p.output)
1458 # Clean update dirs via ant
1460 logging.info("Cleaning subproject %s" % d)
1461 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1463 return (root_dir, srclibpaths)
1466 # Extend via globbing the paths from a field and return them as a map from
1467 # original path to resulting paths
1468 def getpaths_map(build_dir, globpaths):
1472 full_path = os.path.join(build_dir, p)
1473 full_path = os.path.normpath(full_path)
1474 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1478 # Extend via globbing the paths from a field and return them as a set
1479 def getpaths(build_dir, globpaths):
1480 paths_map = getpaths_map(build_dir, globpaths)
1482 for k, v in paths_map.iteritems():
1489 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1495 self.path = os.path.join('stats', 'known_apks.txt')
1497 if os.path.isfile(self.path):
1498 for line in file(self.path):
1499 t = line.rstrip().split(' ')
1501 self.apks[t[0]] = (t[1], None)
1503 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1504 self.changed = False
1506 def writeifchanged(self):
1507 if not self.changed:
1510 if not os.path.exists('stats'):
1514 for apk, app in self.apks.iteritems():
1516 line = apk + ' ' + appid
1518 line += ' ' + time.strftime('%Y-%m-%d', added)
1521 with open(self.path, 'w') as f:
1522 for line in sorted(lst, key=natural_key):
1523 f.write(line + '\n')
1525 # Record an apk (if it's new, otherwise does nothing)
1526 # Returns the date it was added.
1527 def recordapk(self, apk, app):
1528 if apk not in self.apks:
1529 self.apks[apk] = (app, time.gmtime(time.time()))
1531 _, added = self.apks[apk]
1534 # Look up information - given the 'apkname', returns (app id, date added/None).
1535 # Or returns None for an unknown apk.
1536 def getapp(self, apkname):
1537 if apkname in self.apks:
1538 return self.apks[apkname]
1541 # Get the most recent 'num' apps added to the repo, as a list of package ids
1542 # with the most recent first.
1543 def getlatest(self, num):
1545 for apk, app in self.apks.iteritems():
1549 if apps[appid] > added:
1553 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1554 lst = [app for app, _ in sortedapps]
1559 def isApkDebuggable(apkfile, config):
1560 """Returns True if the given apk file is debuggable
1562 :param apkfile: full path to the apk to check"""
1564 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1566 if p.returncode != 0:
1567 logging.critical("Failed to get apk manifest information")
1569 for line in p.output.splitlines():
1570 if 'android:debuggable' in line and not line.endswith('0x0'):
1580 def SdkToolsPopen(commands, cwd=None, output=True):
1582 if cmd not in config:
1583 config[cmd] = find_sdk_tools_cmd(commands[0])
1584 abscmd = config[cmd]
1586 logging.critical("Could not find '%s' on your system" % cmd)
1588 return FDroidPopen(abscmd + commands[1:],
1589 cwd=cwd, output=output)
1592 def FDroidPopen(commands, cwd=None, output=True):
1594 Run a command and capture the possibly huge output.
1596 :param commands: command and argument list like in subprocess.Popen
1597 :param cwd: optionally specifies a working directory
1598 :returns: A PopenResult.
1604 cwd = os.path.normpath(cwd)
1605 logging.debug("Directory: %s" % cwd)
1606 logging.debug("> %s" % ' '.join(commands))
1608 result = PopenResult()
1611 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1612 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1614 raise BuildException("OSError while trying to execute " +
1615 ' '.join(commands) + ': ' + str(e))
1617 stdout_queue = Queue.Queue()
1618 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1620 # Check the queue for output (until there is no more to get)
1621 while not stdout_reader.eof():
1622 while not stdout_queue.empty():
1623 line = stdout_queue.get()
1624 if output and options.verbose:
1625 # Output directly to console
1626 sys.stderr.write(line)
1628 result.output += line
1632 result.returncode = p.wait()
1636 gradle_comment = re.compile(r'[ ]*//')
1639 def remove_signing_keys(build_dir):
1640 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1642 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1643 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1644 re.compile(r'.*variant\.outputFile = .*'),
1645 re.compile(r'.*output\.outputFile = .*'),
1646 re.compile(r'.*\.readLine\(.*'),
1648 for root, dirs, files in os.walk(build_dir):
1649 if 'build.gradle' in files:
1650 path = os.path.join(root, 'build.gradle')
1652 with open(path, "r") as o:
1653 lines = o.readlines()
1659 with open(path, "w") as o:
1660 while i < len(lines):
1663 while line.endswith('\\\n'):
1664 line = line.rstrip('\\\n') + lines[i]
1667 if gradle_comment.match(line):
1672 opened += line.count('{')
1673 opened -= line.count('}')
1676 if signing_configs.match(line):
1681 if any(s.match(line) for s in line_matches):
1689 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1692 'project.properties',
1694 'default.properties',
1695 'ant.properties', ]:
1696 if propfile in files:
1697 path = os.path.join(root, propfile)
1699 with open(path, "r") as o:
1700 lines = o.readlines()
1704 with open(path, "w") as o:
1706 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1713 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1716 def reset_env_path():
1717 global env, orig_path
1718 env['PATH'] = orig_path
1721 def add_to_env_path(path):
1723 paths = env['PATH'].split(os.pathsep)
1727 env['PATH'] = os.pathsep.join(paths)
1730 def replace_config_vars(cmd, build):
1732 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1733 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1734 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1735 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1736 if build is not None:
1737 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1738 cmd = cmd.replace('$$VERSION$$', build['version'])
1739 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1743 def place_srclib(root_dir, number, libpath):
1746 relpath = os.path.relpath(libpath, root_dir)
1747 proppath = os.path.join(root_dir, 'project.properties')
1750 if os.path.isfile(proppath):
1751 with open(proppath, "r") as o:
1752 lines = o.readlines()
1754 with open(proppath, "w") as o:
1757 if line.startswith('android.library.reference.%d=' % number):
1758 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1763 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1766 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1767 """Verify that two apks are the same
1769 One of the inputs is signed, the other is unsigned. The signature metadata
1770 is transferred from the signed to the unsigned apk, and then jarsigner is
1771 used to verify that the signature from the signed apk is also varlid for
1773 :param signed_apk: Path to a signed apk file
1774 :param unsigned_apk: Path to an unsigned apk file expected to match it
1775 :param tmp_dir: Path to directory for temporary files
1776 :returns: None if the verification is successful, otherwise a string
1777 describing what went wrong.
1779 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1780 with ZipFile(signed_apk) as signed_apk_as_zip:
1781 meta_inf_files = ['META-INF/MANIFEST.MF']
1782 for f in signed_apk_as_zip.namelist():
1783 if sigfile.match(f):
1784 meta_inf_files.append(f)
1785 if len(meta_inf_files) < 3:
1786 return "Signature files missing from {0}".format(signed_apk)
1787 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1788 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1789 for meta_inf_file in meta_inf_files:
1790 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1792 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1793 logging.info("...NOT verified - {0}".format(signed_apk))
1794 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1795 logging.info("...successfully verified")
1799 def compare_apks(apk1, apk2, tmp_dir):
1802 Returns None if the apk content is the same (apart from the signing key),
1803 otherwise a string describing what's different, or what went wrong when
1804 trying to do the comparison.
1807 badchars = re.compile('''[/ :;'"]''')
1808 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1809 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1810 for d in [apk1dir, apk2dir]:
1811 if os.path.exists(d):
1814 os.mkdir(os.path.join(d, 'jar-xf'))
1816 if subprocess.call(['jar', 'xf',
1817 os.path.abspath(apk1)],
1818 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1819 return("Failed to unpack " + apk1)
1820 if subprocess.call(['jar', 'xf',
1821 os.path.abspath(apk2)],
1822 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1823 return("Failed to unpack " + apk2)
1825 # try to find apktool in the path, if it hasn't been manually configed
1826 if 'apktool' not in config:
1827 tmp = find_command('apktool')
1829 config['apktool'] = tmp
1830 if 'apktool' in config:
1831 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1833 return("Failed to unpack " + apk1)
1834 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1836 return("Failed to unpack " + apk2)
1838 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1839 lines = p.output.splitlines()
1840 if len(lines) != 1 or 'META-INF' not in lines[0]:
1841 meld = find_command('meld')
1842 if meld is not None:
1843 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1844 return("Unexpected diff output - " + p.output)
1846 # since everything verifies, delete the comparison to keep cruft down
1847 shutil.rmtree(apk1dir)
1848 shutil.rmtree(apk2dir)
1850 # If we get here, it seems like they're the same!
1854 def find_command(command):
1855 '''find the full path of a command, or None if it can't be found in the PATH'''
1858 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1860 fpath, fname = os.path.split(command)
1865 for path in os.environ["PATH"].split(os.pathsep):
1866 path = path.strip('"')
1867 exe_file = os.path.join(path, command)
1868 if is_exe(exe_file):
1875 '''generate a random password for when generating keys'''
1876 h = hashlib.sha256()
1877 h.update(os.urandom(16)) # salt
1878 h.update(bytes(socket.getfqdn()))
1879 return h.digest().encode('base64').strip()
1882 def genkeystore(localconfig):
1883 '''Generate a new key with random passwords and add it to new keystore'''
1884 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1885 keystoredir = os.path.dirname(localconfig['keystore'])
1886 if keystoredir is None or keystoredir == '':
1887 keystoredir = os.path.join(os.getcwd(), keystoredir)
1888 if not os.path.exists(keystoredir):
1889 os.makedirs(keystoredir, mode=0o700)
1891 write_password_file("keystorepass", localconfig['keystorepass'])
1892 write_password_file("keypass", localconfig['keypass'])
1893 p = FDroidPopen(['keytool', '-genkey',
1894 '-keystore', localconfig['keystore'],
1895 '-alias', localconfig['repo_keyalias'],
1896 '-keyalg', 'RSA', '-keysize', '4096',
1897 '-sigalg', 'SHA256withRSA',
1898 '-validity', '10000',
1899 '-storepass:file', config['keystorepassfile'],
1900 '-keypass:file', config['keypassfile'],
1901 '-dname', localconfig['keydname']])
1902 # TODO keypass should be sent via stdin
1903 if p.returncode != 0:
1904 raise BuildException("Failed to generate key", p.output)
1905 os.chmod(localconfig['keystore'], 0o0600)
1906 # now show the lovely key that was just generated
1907 p = FDroidPopen(['keytool', '-list', '-v',
1908 '-keystore', localconfig['keystore'],
1909 '-alias', localconfig['repo_keyalias'],
1910 '-storepass:file', config['keystorepassfile']])
1911 logging.info(p.output.strip() + '\n\n')
1914 def write_to_config(thisconfig, key, value=None):
1915 '''write a key/value to the local config.py'''
1917 origkey = key + '_orig'
1918 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1919 with open('config.py', 'r') as f:
1921 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1922 repl = '\n' + key + ' = "' + value + '"'
1923 data = re.sub(pattern, repl, data)
1924 # if this key is not in the file, append it
1925 if not re.match('\s*' + key + '\s*=\s*"', data):
1927 # make sure the file ends with a carraige return
1928 if not re.match('\n$', data):
1930 with open('config.py', 'w') as f:
1934 def parse_xml(path):
1935 return XMLElementTree.parse(path).getroot()
1938 def string_is_integer(string):
1946 def get_per_app_repos():
1947 '''per-app repos are dirs named with the packageName of a single app'''
1949 # Android packageNames are Java packages, they may contain uppercase or
1950 # lowercase letters ('A' through 'Z'), numbers, and underscores
1951 # ('_'). However, individual package name parts may only start with
1952 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1953 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1956 for root, dirs, files in os.walk(os.getcwd()):
1958 print 'checking', root, 'for', d
1959 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1960 # standard parts of an fdroid repo, so never packageNames
1963 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):