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.
35 import xml.etree.ElementTree as XMLElementTree
39 from Queue import Queue
42 from queue import Queue
44 from zipfile import ZipFile
47 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
50 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
59 'sdk_path': "$ANDROID_HOME",
62 'r10e': "$ANDROID_NDK",
64 'build_tools': "23.0.2",
66 '1.7': "/usr/lib/jvm/java-7-openjdk",
72 'accepted_formats': ['txt', 'yaml'],
73 'sync_from_local_copy_dir': False,
74 'per_app_repos': False,
75 'make_current_version_link': True,
76 'current_version_name_source': 'Name',
77 'update_stats': False,
81 'stats_to_carbon': False,
83 'build_server_always': False,
84 'keystore': 'keystore.jks',
85 'smartcardoptions': [],
91 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
92 'repo_name': "My First FDroid Repo Demo",
93 'repo_icon': "fdroid-icon.png",
94 'repo_description': '''
95 This is a repository of apps to be used with FDroid. Applications in this
96 repository are either official binaries built by the original application
97 developers, or are binaries built from source by the admin of f-droid.org
98 using the tools on https://gitlab.com/u/fdroid.
104 def setup_global_opts(parser):
105 parser.add_argument("-v", "--verbose", action="store_true", default=False,
106 help="Spew out even more information than normal")
107 parser.add_argument("-q", "--quiet", action="store_true", default=False,
108 help="Restrict output to warnings and errors")
111 def fill_config_defaults(thisconfig):
112 for k, v in default_config.items():
113 if k not in thisconfig:
116 # Expand paths (~users and $vars)
117 def expand_path(path):
121 path = os.path.expanduser(path)
122 path = os.path.expandvars(path)
127 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
132 thisconfig[k + '_orig'] = v
134 for k in ['ndk_paths', 'java_paths']:
140 thisconfig[k][k2] = exp
141 thisconfig[k][k2 + '_orig'] = v
144 def regsub_file(pattern, repl, path):
145 with open(path, 'r') as f:
147 text = re.sub(pattern, repl, text)
148 with open(path, 'w') as f:
152 def read_config(opts, config_file='config.py'):
153 """Read the repository config
155 The config is read from config_file, which is in the current directory when
156 any of the repo management commands are used.
158 global config, options, env, orig_path
160 if config is not None:
162 if not os.path.isfile(config_file):
163 logging.critical("Missing config file - is this a repo directory?")
170 logging.debug("Reading %s" % config_file)
171 execfile(config_file, config)
173 # smartcardoptions must be a list since its command line args for Popen
174 if 'smartcardoptions' in config:
175 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
176 elif 'keystore' in config and config['keystore'] == 'NONE':
177 # keystore='NONE' means use smartcard, these are required defaults
178 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
179 'SunPKCS11-OpenSC', '-providerClass',
180 'sun.security.pkcs11.SunPKCS11',
181 '-providerArg', 'opensc-fdroid.cfg']
183 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
184 st = os.stat(config_file)
185 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
186 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
188 fill_config_defaults(config)
190 # There is no standard, so just set up the most common environment
193 orig_path = env['PATH']
194 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
195 env[n] = config['sdk_path']
198 cpath = config['java_paths']['1.%s' % v]
200 env['JAVA%s_HOME' % v] = cpath
202 for k in ["keystorepass", "keypass"]:
204 write_password_file(k)
206 for k in ["repo_description", "archive_description"]:
208 config[k] = clean_description(config[k])
210 if 'serverwebroot' in config:
211 if isinstance(config['serverwebroot'], basestring):
212 roots = [config['serverwebroot']]
213 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
214 roots = config['serverwebroot']
216 raise TypeError('only accepts strings, lists, and tuples')
218 for rootstr in roots:
219 # since this is used with rsync, where trailing slashes have
220 # meaning, ensure there is always a trailing slash
221 if rootstr[-1] != '/':
223 rootlist.append(rootstr.replace('//', '/'))
224 config['serverwebroot'] = rootlist
229 def find_sdk_tools_cmd(cmd):
230 '''find a working path to a tool from the Android SDK'''
233 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
234 # try to find a working path to this command, in all the recent possible paths
235 if 'build_tools' in config:
236 build_tools = os.path.join(config['sdk_path'], 'build-tools')
237 # if 'build_tools' was manually set and exists, check only that one
238 configed_build_tools = os.path.join(build_tools, config['build_tools'])
239 if os.path.exists(configed_build_tools):
240 tooldirs.append(configed_build_tools)
242 # no configed version, so hunt known paths for it
243 for f in sorted(os.listdir(build_tools), reverse=True):
244 if os.path.isdir(os.path.join(build_tools, f)):
245 tooldirs.append(os.path.join(build_tools, f))
246 tooldirs.append(build_tools)
247 sdk_tools = os.path.join(config['sdk_path'], 'tools')
248 if os.path.exists(sdk_tools):
249 tooldirs.append(sdk_tools)
250 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
251 if os.path.exists(sdk_platform_tools):
252 tooldirs.append(sdk_platform_tools)
253 tooldirs.append('/usr/bin')
255 if os.path.isfile(os.path.join(d, cmd)):
256 return os.path.join(d, cmd)
257 # did not find the command, exit with error message
258 ensure_build_tools_exists(config)
261 def test_sdk_exists(thisconfig):
262 if 'sdk_path' not in thisconfig:
263 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
266 logging.error("'sdk_path' not set in config.py!")
268 if thisconfig['sdk_path'] == default_config['sdk_path']:
269 logging.error('No Android SDK found!')
270 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
271 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
273 if not os.path.exists(thisconfig['sdk_path']):
274 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
276 if not os.path.isdir(thisconfig['sdk_path']):
277 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
279 for d in ['build-tools', 'platform-tools', 'tools']:
280 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
281 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
282 thisconfig['sdk_path'], d))
287 def ensure_build_tools_exists(thisconfig):
288 if not test_sdk_exists(thisconfig):
290 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
291 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
292 if not os.path.isdir(versioned_build_tools):
293 logging.critical('Android Build Tools path "'
294 + versioned_build_tools + '" does not exist!')
298 def write_password_file(pwtype, password=None):
300 writes out passwords to a protected file instead of passing passwords as
301 command line argments
303 filename = '.fdroid.' + pwtype + '.txt'
304 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
306 os.write(fd, config[pwtype])
308 os.write(fd, password)
310 config[pwtype + 'file'] = filename
313 # Given the arguments in the form of multiple appid:[vc] strings, this returns
314 # a dictionary with the set of vercodes specified for each package.
315 def read_pkg_args(args, allow_vercodes=False):
322 if allow_vercodes and ':' in p:
323 package, vercode = p.split(':')
325 package, vercode = p, None
326 if package not in vercodes:
327 vercodes[package] = [vercode] if vercode else []
329 elif vercode and vercode not in vercodes[package]:
330 vercodes[package] += [vercode] if vercode else []
335 # On top of what read_pkg_args does, this returns the whole app metadata, but
336 # limiting the builds list to the builds matching the vercodes specified.
337 def read_app_args(args, allapps, allow_vercodes=False):
339 vercodes = read_pkg_args(args, allow_vercodes)
345 for appid, app in allapps.iteritems():
346 if appid in vercodes:
349 if len(apps) != len(vercodes):
352 logging.critical("No such package: %s" % p)
353 raise FDroidException("Found invalid app ids in arguments")
355 raise FDroidException("No packages specified")
358 for appid, app in apps.iteritems():
362 app.builds = [b for b in app.builds if b.vercode in vc]
363 if len(app.builds) != len(vercodes[appid]):
365 allvcs = [b.vercode for b in app.builds]
366 for v in vercodes[appid]:
368 logging.critical("No such vercode %s for app %s" % (v, appid))
371 raise FDroidException("Found invalid vercodes for some apps")
376 def get_extension(filename):
377 base, ext = os.path.splitext(filename)
380 return base, ext.lower()[1:]
383 def has_extension(filename, ext):
384 _, f_ext = get_extension(filename)
388 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
391 def clean_description(description):
392 'Remove unneeded newlines and spaces from a block of description text'
394 # this is split up by paragraph to make removing the newlines easier
395 for paragraph in re.split(r'\n\n', description):
396 paragraph = re.sub('\r', '', paragraph)
397 paragraph = re.sub('\n', ' ', paragraph)
398 paragraph = re.sub(' {2,}', ' ', paragraph)
399 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
400 returnstring += paragraph + '\n\n'
401 return returnstring.rstrip('\n')
404 def apknameinfo(filename):
405 filename = os.path.basename(filename)
406 m = apk_regex.match(filename)
408 result = (m.group(1), m.group(2))
409 except AttributeError:
410 raise FDroidException("Invalid apk name: %s" % filename)
414 def getapkname(app, build):
415 return "%s_%s.apk" % (app.id, build.vercode)
418 def getsrcname(app, build):
419 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
431 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
434 def getvcs(vcstype, remote, local):
436 return vcs_git(remote, local)
437 if vcstype == 'git-svn':
438 return vcs_gitsvn(remote, local)
440 return vcs_hg(remote, local)
442 return vcs_bzr(remote, local)
443 if vcstype == 'srclib':
444 if local != os.path.join('build', 'srclib', remote):
445 raise VCSException("Error: srclib paths are hard-coded!")
446 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
448 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
449 raise VCSException("Invalid vcs type " + vcstype)
452 def getsrclibvcs(name):
453 if name not in metadata.srclibs:
454 raise VCSException("Missing srclib " + name)
455 return metadata.srclibs[name]['Repo Type']
460 def __init__(self, remote, local):
462 # svn, git-svn and bzr may require auth
464 if self.repotype() in ('git-svn', 'bzr'):
466 if self.repotype == 'git-svn':
467 raise VCSException("Authentication is not supported for git-svn")
468 self.username, remote = remote.split('@')
469 if ':' not in self.username:
470 raise VCSException("Password required with username")
471 self.username, self.password = self.username.split(':')
475 self.clone_failed = False
476 self.refreshed = False
482 # Take the local repository to a clean version of the given revision, which
483 # is specificed in the VCS's native format. Beforehand, the repository can
484 # be dirty, or even non-existent. If the repository does already exist
485 # locally, it will be updated from the origin, but only once in the
486 # lifetime of the vcs object.
487 # None is acceptable for 'rev' if you know you are cloning a clean copy of
488 # the repo - otherwise it must specify a valid revision.
489 def gotorevision(self, rev, refresh=True):
491 if self.clone_failed:
492 raise VCSException("Downloading the repository already failed once, not trying again.")
494 # The .fdroidvcs-id file for a repo tells us what VCS type
495 # and remote that directory was created from, allowing us to drop it
496 # automatically if either of those things changes.
497 fdpath = os.path.join(self.local, '..',
498 '.fdroidvcs-' + os.path.basename(self.local))
499 cdata = self.repotype() + ' ' + self.remote
502 if os.path.exists(self.local):
503 if os.path.exists(fdpath):
504 with open(fdpath, 'r') as f:
505 fsdata = f.read().strip()
510 logging.info("Repository details for %s changed - deleting" % (
514 logging.info("Repository details for %s missing - deleting" % (
517 shutil.rmtree(self.local)
521 self.refreshed = True
524 self.gotorevisionx(rev)
525 except FDroidException as e:
528 # If necessary, write the .fdroidvcs file.
529 if writeback and not self.clone_failed:
530 with open(fdpath, 'w') as f:
536 # Derived classes need to implement this. It's called once basic checking
537 # has been performend.
538 def gotorevisionx(self, rev):
539 raise VCSException("This VCS type doesn't define gotorevisionx")
541 # Initialise and update submodules
542 def initsubmodules(self):
543 raise VCSException('Submodules not supported for this vcs type')
545 # Get a list of all known tags
547 if not self._gettags:
548 raise VCSException('gettags not supported for this vcs type')
550 for tag in self._gettags():
551 if re.match('[-A-Za-z0-9_. /]+$', tag):
555 def latesttags(self, tags, number):
556 """Get the most recent tags in a given list.
558 :param tags: a list of tags
559 :param number: the number to return
560 :returns: A list containing the most recent tags in the provided
561 list, up to the maximum number given.
563 raise VCSException('latesttags not supported for this vcs type')
565 # Get current commit reference (hash, revision, etc)
567 raise VCSException('getref not supported for this vcs type')
569 # Returns the srclib (name, path) used in setting up the current
580 # If the local directory exists, but is somehow not a git repository, git
581 # will traverse up the directory tree until it finds one that is (i.e.
582 # fdroidserver) and then we'll proceed to destroy it! This is called as
585 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
586 result = p.output.rstrip()
587 if not result.endswith(self.local):
588 raise VCSException('Repository mismatch')
590 def gotorevisionx(self, rev):
591 if not os.path.exists(self.local):
593 p = FDroidPopen(['git', 'clone', self.remote, self.local])
594 if p.returncode != 0:
595 self.clone_failed = True
596 raise VCSException("Git clone failed", p.output)
600 # Discard any working tree changes
601 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
602 'git', 'reset', '--hard'], cwd=self.local, output=False)
603 if p.returncode != 0:
604 raise VCSException("Git reset failed", p.output)
605 # Remove untracked files now, in case they're tracked in the target
606 # revision (it happens!)
607 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
608 'git', 'clean', '-dffx'], cwd=self.local, output=False)
609 if p.returncode != 0:
610 raise VCSException("Git clean failed", p.output)
611 if not self.refreshed:
612 # Get latest commits and tags from remote
613 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
614 if p.returncode != 0:
615 raise VCSException("Git fetch failed", p.output)
616 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
617 if p.returncode != 0:
618 raise VCSException("Git fetch failed", p.output)
619 # Recreate origin/HEAD as git clone would do it, in case it disappeared
620 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
621 if p.returncode != 0:
622 lines = p.output.splitlines()
623 if 'Multiple remote HEAD branches' not in lines[0]:
624 raise VCSException("Git remote set-head failed", p.output)
625 branch = lines[1].split(' ')[-1]
626 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
627 if p2.returncode != 0:
628 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
629 self.refreshed = True
630 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
631 # a github repo. Most of the time this is the same as origin/master.
632 rev = rev or 'origin/HEAD'
633 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
634 if p.returncode != 0:
635 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
636 # Get rid of any uncontrolled files left behind
637 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
638 if p.returncode != 0:
639 raise VCSException("Git clean failed", p.output)
641 def initsubmodules(self):
643 submfile = os.path.join(self.local, '.gitmodules')
644 if not os.path.isfile(submfile):
645 raise VCSException("No git submodules available")
647 # fix submodules not accessible without an account and public key auth
648 with open(submfile, 'r') as f:
649 lines = f.readlines()
650 with open(submfile, 'w') as f:
652 if 'git@github.com' in line:
653 line = line.replace('git@github.com:', 'https://github.com/')
654 if 'git@gitlab.com' in line:
655 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
658 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
659 if p.returncode != 0:
660 raise VCSException("Git submodule sync failed", p.output)
661 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
662 if p.returncode != 0:
663 raise VCSException("Git submodule update failed", p.output)
667 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
668 return p.output.splitlines()
670 def latesttags(self, tags, number):
675 ['git', 'show', '--format=format:%ct', '-s', tag],
676 cwd=self.local, output=False)
677 # Timestamp is on the last line. For a normal tag, it's the only
678 # line, but for annotated tags, the rest of the info precedes it.
679 ts = int(p.output.splitlines()[-1])
682 for _, t in sorted(tl)[-number:]:
687 class vcs_gitsvn(vcs):
692 # If the local directory exists, but is somehow not a git repository, git
693 # will traverse up the directory tree until it finds one that is (i.e.
694 # fdroidserver) and then we'll proceed to destory it! This is called as
697 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
698 result = p.output.rstrip()
699 if not result.endswith(self.local):
700 raise VCSException('Repository mismatch')
702 def gotorevisionx(self, rev):
703 if not os.path.exists(self.local):
705 gitsvn_args = ['git', 'svn', 'clone']
706 if ';' in self.remote:
707 remote_split = self.remote.split(';')
708 for i in remote_split[1:]:
709 if i.startswith('trunk='):
710 gitsvn_args.extend(['-T', i[6:]])
711 elif i.startswith('tags='):
712 gitsvn_args.extend(['-t', i[5:]])
713 elif i.startswith('branches='):
714 gitsvn_args.extend(['-b', i[9:]])
715 gitsvn_args.extend([remote_split[0], 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)
721 gitsvn_args.extend([self.remote, self.local])
722 p = FDroidPopen(gitsvn_args, output=False)
723 if p.returncode != 0:
724 self.clone_failed = True
725 raise VCSException("Git svn clone failed", p.output)
729 # Discard any working tree changes
730 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
731 if p.returncode != 0:
732 raise VCSException("Git reset failed", p.output)
733 # Remove untracked files now, in case they're tracked in the target
734 # revision (it happens!)
735 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
736 if p.returncode != 0:
737 raise VCSException("Git clean failed", p.output)
738 if not self.refreshed:
739 # Get new commits, branches and tags from repo
740 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
741 if p.returncode != 0:
742 raise VCSException("Git svn fetch failed")
743 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
744 if p.returncode != 0:
745 raise VCSException("Git svn rebase failed", p.output)
746 self.refreshed = True
748 rev = rev or 'master'
750 nospaces_rev = rev.replace(' ', '%20')
751 # Try finding a svn tag
752 for treeish in ['origin/', '']:
753 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
754 if p.returncode == 0:
756 if p.returncode != 0:
757 # No tag found, normal svn rev translation
758 # Translate svn rev into git format
759 rev_split = rev.split('/')
762 for treeish in ['origin/', '']:
763 if len(rev_split) > 1:
764 treeish += rev_split[0]
765 svn_rev = rev_split[1]
768 # if no branch is specified, then assume trunk (i.e. 'master' branch):
772 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
774 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
775 git_rev = p.output.rstrip()
777 if p.returncode == 0 and git_rev:
780 if p.returncode != 0 or not git_rev:
781 # Try a plain git checkout as a last resort
782 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
783 if p.returncode != 0:
784 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
786 # Check out the git rev equivalent to the svn rev
787 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
788 if p.returncode != 0:
789 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
791 # Get rid of any uncontrolled files left behind
792 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
793 if p.returncode != 0:
794 raise VCSException("Git clean failed", p.output)
798 for treeish in ['origin/', '']:
799 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
805 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
806 if p.returncode != 0:
808 return p.output.strip()
816 def gotorevisionx(self, rev):
817 if not os.path.exists(self.local):
818 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
819 if p.returncode != 0:
820 self.clone_failed = True
821 raise VCSException("Hg clone failed", p.output)
823 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
824 if p.returncode != 0:
825 raise VCSException("Hg status failed", p.output)
826 for line in p.output.splitlines():
827 if not line.startswith('? '):
828 raise VCSException("Unexpected output from hg status -uS: " + line)
829 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
830 if not self.refreshed:
831 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
832 if p.returncode != 0:
833 raise VCSException("Hg pull failed", p.output)
834 self.refreshed = True
836 rev = rev or 'default'
839 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
840 if p.returncode != 0:
841 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
842 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
843 # Also delete untracked files, we have to enable purge extension for that:
844 if "'purge' is provided by the following extension" in p.output:
845 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
846 myfile.write("\n[extensions]\nhgext.purge=\n")
847 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
848 if p.returncode != 0:
849 raise VCSException("HG purge failed", p.output)
850 elif p.returncode != 0:
851 raise VCSException("HG purge failed", p.output)
854 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
855 return p.output.splitlines()[1:]
863 def gotorevisionx(self, rev):
864 if not os.path.exists(self.local):
865 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
866 if p.returncode != 0:
867 self.clone_failed = True
868 raise VCSException("Bzr branch failed", p.output)
870 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Bzr revert failed", p.output)
873 if not self.refreshed:
874 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
875 if p.returncode != 0:
876 raise VCSException("Bzr update failed", p.output)
877 self.refreshed = True
879 revargs = list(['-r', rev] if rev else [])
880 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
881 if p.returncode != 0:
882 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
885 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
886 return [tag.split(' ')[0].strip() for tag in
887 p.output.splitlines()]
890 def unescape_string(string):
893 if string[0] == '"' and string[-1] == '"':
896 return string.replace("\\'", "'")
899 def retrieve_string(app_dir, string, xmlfiles=None):
901 if not string.startswith('@string/'):
902 return unescape_string(string)
907 os.path.join(app_dir, 'res'),
908 os.path.join(app_dir, 'src', 'main', 'res'),
910 for r, d, f in os.walk(res_dir):
911 if os.path.basename(r) == 'values':
912 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
914 name = string[len('@string/'):]
916 def element_content(element):
917 if element.text is None:
919 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
922 for path in xmlfiles:
923 if not os.path.isfile(path):
925 xml = parse_xml(path)
926 element = xml.find('string[@name="' + name + '"]')
927 if element is not None:
928 content = element_content(element)
929 return retrieve_string(app_dir, content, xmlfiles)
934 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
935 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
938 # Return list of existing files that will be used to find the highest vercode
939 def manifest_paths(app_dir, flavours):
941 possible_manifests = \
942 [os.path.join(app_dir, 'AndroidManifest.xml'),
943 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
944 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
945 os.path.join(app_dir, 'build.gradle')]
947 for flavour in flavours:
950 possible_manifests.append(
951 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
953 return [path for path in possible_manifests if os.path.isfile(path)]
956 # Retrieve the package name. Returns the name, or None if not found.
957 def fetch_real_name(app_dir, flavours):
958 for path in manifest_paths(app_dir, flavours):
959 if not has_extension(path, 'xml') or not os.path.isfile(path):
961 logging.debug("fetch_real_name: Checking manifest at " + path)
962 xml = parse_xml(path)
963 app = xml.find('application')
966 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
968 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
969 result = retrieve_string_singleline(app_dir, label)
971 result = result.strip()
976 def get_library_references(root_dir):
978 proppath = os.path.join(root_dir, 'project.properties')
979 if not os.path.isfile(proppath):
981 for line in file(proppath):
982 if not line.startswith('android.library.reference.'):
984 path = line.split('=')[1].strip()
985 relpath = os.path.join(root_dir, path)
986 if not os.path.isdir(relpath):
988 logging.debug("Found subproject at %s" % path)
989 libraries.append(path)
993 def ant_subprojects(root_dir):
994 subprojects = get_library_references(root_dir)
995 for subpath in subprojects:
996 subrelpath = os.path.join(root_dir, subpath)
997 for p in get_library_references(subrelpath):
998 relp = os.path.normpath(os.path.join(subpath, p))
999 if relp not in subprojects:
1000 subprojects.insert(0, relp)
1004 def remove_debuggable_flags(root_dir):
1005 # Remove forced debuggable flags
1006 logging.debug("Removing debuggable flags from %s" % root_dir)
1007 for root, dirs, files in os.walk(root_dir):
1008 if 'AndroidManifest.xml' in files:
1009 regsub_file(r'android:debuggable="[^"]*"',
1011 os.path.join(root, 'AndroidManifest.xml'))
1014 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1015 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1016 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1019 def app_matches_packagename(app, package):
1022 appid = app.UpdateCheckName or app.id
1023 if appid is None or appid == "Ignore":
1025 return appid == package
1028 # Extract some information from the AndroidManifest.xml at the given path.
1029 # Returns (version, vercode, package), any or all of which might be None.
1030 # All values returned are strings.
1031 def parse_androidmanifests(paths, app):
1033 ignoreversions = app.UpdateCheckIgnore
1034 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1037 return (None, None, None)
1045 if not os.path.isfile(path):
1048 logging.debug("Parsing manifest at {0}".format(path))
1049 gradle = has_extension(path, 'gradle')
1055 for line in file(path):
1056 if gradle_comment.match(line):
1058 # Grab first occurence of each to avoid running into
1059 # alternative flavours and builds.
1061 matches = psearch_g(line)
1063 s = matches.group(2)
1064 if app_matches_packagename(app, s):
1067 matches = vnsearch_g(line)
1069 version = matches.group(2)
1071 matches = vcsearch_g(line)
1073 vercode = matches.group(1)
1076 xml = parse_xml(path)
1077 if "package" in xml.attrib:
1078 s = xml.attrib["package"].encode('utf-8')
1079 if app_matches_packagename(app, s):
1081 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1082 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1083 base_dir = os.path.dirname(path)
1084 version = retrieve_string_singleline(base_dir, version)
1085 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1086 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1087 if string_is_integer(a):
1090 logging.warning("Problem with xml at {0}".format(path))
1092 # Remember package name, may be defined separately from version+vercode
1094 package = max_package
1096 logging.debug("..got package={0}, version={1}, vercode={2}"
1097 .format(package, version, vercode))
1099 # Always grab the package name and version name in case they are not
1100 # together with the highest version code
1101 if max_package is None and package is not None:
1102 max_package = package
1103 if max_version is None and version is not None:
1104 max_version = version
1106 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1107 if not ignoresearch or not ignoresearch(version):
1108 if version is not None:
1109 max_version = version
1110 if vercode is not None:
1111 max_vercode = vercode
1112 if package is not None:
1113 max_package = package
1115 max_version = "Ignore"
1117 if max_version is None:
1118 max_version = "Unknown"
1120 if max_package and not is_valid_package_name(max_package):
1121 raise FDroidException("Invalid package name {0}".format(max_package))
1123 return (max_version, max_vercode, max_package)
1126 def is_valid_package_name(name):
1127 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1130 class FDroidException(Exception):
1132 def __init__(self, value, detail=None):
1134 self.detail = detail
1136 def shortened_detail(self):
1137 if len(self.detail) < 16000:
1139 return '[...]\n' + self.detail[-16000:]
1141 def get_wikitext(self):
1142 ret = repr(self.value) + "\n"
1145 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1151 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1155 class VCSException(FDroidException):
1159 class BuildException(FDroidException):
1163 # Get the specified source library.
1164 # Returns the path to it. Normally this is the path to be used when referencing
1165 # it, which may be a subdirectory of the actual project. If you want the base
1166 # directory of the project, pass 'basepath=True'.
1167 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1168 raw=False, prepare=True, preponly=False, refresh=True):
1176 name, ref = spec.split('@')
1178 number, name = name.split(':', 1)
1180 name, subdir = name.split('/', 1)
1182 if name not in metadata.srclibs:
1183 raise VCSException('srclib ' + name + ' not found.')
1185 srclib = metadata.srclibs[name]
1187 sdir = os.path.join(srclib_dir, name)
1190 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1191 vcs.srclib = (name, number, sdir)
1193 vcs.gotorevision(ref, refresh)
1200 libdir = os.path.join(sdir, subdir)
1201 elif srclib["Subdir"]:
1202 for subdir in srclib["Subdir"]:
1203 libdir_candidate = os.path.join(sdir, subdir)
1204 if os.path.exists(libdir_candidate):
1205 libdir = libdir_candidate
1211 remove_signing_keys(sdir)
1212 remove_debuggable_flags(sdir)
1216 if srclib["Prepare"]:
1217 cmd = replace_config_vars(srclib["Prepare"], None)
1219 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1220 if p.returncode != 0:
1221 raise BuildException("Error running prepare command for srclib %s"
1227 return (name, number, libdir)
1229 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1232 # Prepare the source code for a particular build
1233 # 'vcs' - the appropriate vcs object for the application
1234 # 'app' - the application details from the metadata
1235 # 'build' - the build details from the metadata
1236 # 'build_dir' - the path to the build directory, usually
1238 # 'srclib_dir' - the path to the source libraries directory, usually
1240 # 'extlib_dir' - the path to the external libraries directory, usually
1242 # Returns the (root, srclibpaths) where:
1243 # 'root' is the root directory, which may be the same as 'build_dir' or may
1244 # be a subdirectory of it.
1245 # 'srclibpaths' is information on the srclibs being used
1246 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1248 # Optionally, the actual app source can be in a subdirectory
1250 root_dir = os.path.join(build_dir, build.subdir)
1252 root_dir = build_dir
1254 # Get a working copy of the right revision
1255 logging.info("Getting source for revision " + build.commit)
1256 vcs.gotorevision(build.commit, refresh)
1258 # Initialise submodules if required
1259 if build.submodules:
1260 logging.info("Initialising submodules")
1261 vcs.initsubmodules()
1263 # Check that a subdir (if we're using one) exists. This has to happen
1264 # after the checkout, since it might not exist elsewhere
1265 if not os.path.exists(root_dir):
1266 raise BuildException('Missing subdir ' + root_dir)
1268 # Run an init command if one is required
1270 cmd = replace_config_vars(build.init, build)
1271 logging.info("Running 'init' commands in %s" % root_dir)
1273 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1274 if p.returncode != 0:
1275 raise BuildException("Error running init command for %s:%s" %
1276 (app.id, build.version), p.output)
1278 # Apply patches if any
1280 logging.info("Applying patches")
1281 for patch in build.patch:
1282 patch = patch.strip()
1283 logging.info("Applying " + patch)
1284 patch_path = os.path.join('metadata', app.id, patch)
1285 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1286 if p.returncode != 0:
1287 raise BuildException("Failed to apply patch %s" % patch_path)
1289 # Get required source libraries
1292 logging.info("Collecting source libraries")
1293 for lib in build.srclibs:
1294 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1296 for name, number, libpath in srclibpaths:
1297 place_srclib(root_dir, int(number) if number else None, libpath)
1299 basesrclib = vcs.getsrclib()
1300 # If one was used for the main source, add that too.
1302 srclibpaths.append(basesrclib)
1304 # Update the local.properties file
1305 localprops = [os.path.join(build_dir, 'local.properties')]
1307 parts = build.subdir.split(os.sep)
1310 cur = os.path.join(cur, d)
1311 localprops += [os.path.join(cur, 'local.properties')]
1312 for path in localprops:
1314 if os.path.isfile(path):
1315 logging.info("Updating local.properties file at %s" % path)
1316 with open(path, 'r') as f:
1320 logging.info("Creating local.properties file at %s" % path)
1321 # Fix old-fashioned 'sdk-location' by copying
1322 # from sdk.dir, if necessary
1324 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1325 re.S | re.M).group(1)
1326 props += "sdk-location=%s\n" % sdkloc
1328 props += "sdk.dir=%s\n" % config['sdk_path']
1329 props += "sdk-location=%s\n" % config['sdk_path']
1330 ndk_path = build.ndk_path()
1333 props += "ndk.dir=%s\n" % ndk_path
1334 props += "ndk-location=%s\n" % ndk_path
1335 # Add java.encoding if necessary
1337 props += "java.encoding=%s\n" % build.encoding
1338 with open(path, 'w') as f:
1342 if build.method() == 'gradle':
1343 flavours = build.gradle
1346 n = build.target.split('-')[1]
1347 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1348 r'compileSdkVersion %s' % n,
1349 os.path.join(root_dir, 'build.gradle'))
1351 # Remove forced debuggable flags
1352 remove_debuggable_flags(root_dir)
1354 # Insert version code and number into the manifest if necessary
1355 if build.forceversion:
1356 logging.info("Changing the version name")
1357 for path in manifest_paths(root_dir, flavours):
1358 if not os.path.isfile(path):
1360 if has_extension(path, 'xml'):
1361 regsub_file(r'android:versionName="[^"]*"',
1362 r'android:versionName="%s"' % build.version,
1364 elif has_extension(path, 'gradle'):
1365 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1366 r"""\1versionName '%s'""" % build.version,
1369 if build.forcevercode:
1370 logging.info("Changing the version code")
1371 for path in manifest_paths(root_dir, flavours):
1372 if not os.path.isfile(path):
1374 if has_extension(path, 'xml'):
1375 regsub_file(r'android:versionCode="[^"]*"',
1376 r'android:versionCode="%s"' % build.vercode,
1378 elif has_extension(path, 'gradle'):
1379 regsub_file(r'versionCode[ =]+[0-9]+',
1380 r'versionCode %s' % build.vercode,
1383 # Delete unwanted files
1385 logging.info("Removing specified files")
1386 for part in getpaths(build_dir, build.rm):
1387 dest = os.path.join(build_dir, part)
1388 logging.info("Removing {0}".format(part))
1389 if os.path.lexists(dest):
1390 if os.path.islink(dest):
1391 FDroidPopen(['unlink', dest], output=False)
1393 FDroidPopen(['rm', '-rf', dest], output=False)
1395 logging.info("...but it didn't exist")
1397 remove_signing_keys(build_dir)
1399 # Add required external libraries
1401 logging.info("Collecting prebuilt libraries")
1402 libsdir = os.path.join(root_dir, 'libs')
1403 if not os.path.exists(libsdir):
1405 for lib in build.extlibs:
1407 logging.info("...installing extlib {0}".format(lib))
1408 libf = os.path.basename(lib)
1409 libsrc = os.path.join(extlib_dir, lib)
1410 if not os.path.exists(libsrc):
1411 raise BuildException("Missing extlib file {0}".format(libsrc))
1412 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1414 # Run a pre-build command if one is required
1416 logging.info("Running 'prebuild' commands in %s" % root_dir)
1418 cmd = replace_config_vars(build.prebuild, build)
1420 # Substitute source library paths into prebuild commands
1421 for name, number, libpath in srclibpaths:
1422 libpath = os.path.relpath(libpath, root_dir)
1423 cmd = cmd.replace('$$' + name + '$$', libpath)
1425 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1426 if p.returncode != 0:
1427 raise BuildException("Error running prebuild command for %s:%s" %
1428 (app.id, build.version), p.output)
1430 # Generate (or update) the ant build file, build.xml...
1431 if build.method() == 'ant' and build.update != ['no']:
1432 parms = ['android', 'update', 'lib-project']
1433 lparms = ['android', 'update', 'project']
1436 parms += ['-t', build.target]
1437 lparms += ['-t', build.target]
1439 update_dirs = build.update
1441 update_dirs = ant_subprojects(root_dir) + ['.']
1443 for d in update_dirs:
1444 subdir = os.path.join(root_dir, d)
1446 logging.debug("Updating main project")
1447 cmd = parms + ['-p', d]
1449 logging.debug("Updating subproject %s" % d)
1450 cmd = lparms + ['-p', d]
1451 p = SdkToolsPopen(cmd, cwd=root_dir)
1452 # Check to see whether an error was returned without a proper exit
1453 # code (this is the case for the 'no target set or target invalid'
1455 if p.returncode != 0 or p.output.startswith("Error: "):
1456 raise BuildException("Failed to update project at %s" % d, p.output)
1457 # Clean update dirs via ant
1459 logging.info("Cleaning subproject %s" % d)
1460 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1462 return (root_dir, srclibpaths)
1465 # Extend via globbing the paths from a field and return them as a map from
1466 # original path to resulting paths
1467 def getpaths_map(build_dir, globpaths):
1471 full_path = os.path.join(build_dir, p)
1472 full_path = os.path.normpath(full_path)
1473 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1475 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1479 # Extend via globbing the paths from a field and return them as a set
1480 def getpaths(build_dir, globpaths):
1481 paths_map = getpaths_map(build_dir, globpaths)
1483 for k, v in paths_map.iteritems():
1490 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1496 self.path = os.path.join('stats', 'known_apks.txt')
1498 if os.path.isfile(self.path):
1499 for line in file(self.path):
1500 t = line.rstrip().split(' ')
1502 self.apks[t[0]] = (t[1], None)
1504 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1505 self.changed = False
1507 def writeifchanged(self):
1508 if not self.changed:
1511 if not os.path.exists('stats'):
1515 for apk, app in self.apks.iteritems():
1517 line = apk + ' ' + appid
1519 line += ' ' + time.strftime('%Y-%m-%d', added)
1522 with open(self.path, 'w') as f:
1523 for line in sorted(lst, key=natural_key):
1524 f.write(line + '\n')
1526 # Record an apk (if it's new, otherwise does nothing)
1527 # Returns the date it was added.
1528 def recordapk(self, apk, app):
1529 if apk not in self.apks:
1530 self.apks[apk] = (app, time.gmtime(time.time()))
1532 _, added = self.apks[apk]
1535 # Look up information - given the 'apkname', returns (app id, date added/None).
1536 # Or returns None for an unknown apk.
1537 def getapp(self, apkname):
1538 if apkname in self.apks:
1539 return self.apks[apkname]
1542 # Get the most recent 'num' apps added to the repo, as a list of package ids
1543 # with the most recent first.
1544 def getlatest(self, num):
1546 for apk, app in self.apks.iteritems():
1550 if apps[appid] > added:
1554 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1555 lst = [app for app, _ in sortedapps]
1560 def isApkDebuggable(apkfile, config):
1561 """Returns True if the given apk file is debuggable
1563 :param apkfile: full path to the apk to check"""
1565 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1567 if p.returncode != 0:
1568 logging.critical("Failed to get apk manifest information")
1570 for line in p.output.splitlines():
1571 if 'android:debuggable' in line and not line.endswith('0x0'):
1581 def SdkToolsPopen(commands, cwd=None, output=True):
1583 if cmd not in config:
1584 config[cmd] = find_sdk_tools_cmd(commands[0])
1585 abscmd = config[cmd]
1587 logging.critical("Could not find '%s' on your system" % cmd)
1589 return FDroidPopen([abscmd] + commands[1:],
1590 cwd=cwd, output=output)
1593 def FDroidPopen(commands, cwd=None, output=True):
1595 Run a command and capture the possibly huge output.
1597 :param commands: command and argument list like in subprocess.Popen
1598 :param cwd: optionally specifies a working directory
1599 :returns: A PopenResult.
1605 cwd = os.path.normpath(cwd)
1606 logging.debug("Directory: %s" % cwd)
1607 logging.debug("> %s" % ' '.join(commands))
1609 result = PopenResult()
1612 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1613 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1614 except OSError as e:
1615 raise BuildException("OSError while trying to execute " +
1616 ' '.join(commands) + ': ' + str(e))
1618 stdout_queue = Queue()
1619 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1621 # Check the queue for output (until there is no more to get)
1622 while not stdout_reader.eof():
1623 while not stdout_queue.empty():
1624 line = stdout_queue.get()
1625 if output and options.verbose:
1626 # Output directly to console
1627 sys.stderr.write(line)
1629 result.output += line
1633 result.returncode = p.wait()
1637 gradle_comment = re.compile(r'[ ]*//')
1638 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1639 gradle_line_matches = [
1640 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1641 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1642 re.compile(r'.*\.readLine\(.*'),
1646 def remove_signing_keys(build_dir):
1647 for root, dirs, files in os.walk(build_dir):
1648 if 'build.gradle' in files:
1649 path = os.path.join(root, 'build.gradle')
1651 with open(path, "r") as o:
1652 lines = o.readlines()
1658 with open(path, "w") as o:
1659 while i < len(lines):
1662 while line.endswith('\\\n'):
1663 line = line.rstrip('\\\n') + lines[i]
1666 if gradle_comment.match(line):
1671 opened += line.count('{')
1672 opened -= line.count('}')
1675 if gradle_signing_configs.match(line):
1680 if any(s.match(line) for s in gradle_line_matches):
1688 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1691 'project.properties',
1693 'default.properties',
1694 'ant.properties', ]:
1695 if propfile in files:
1696 path = os.path.join(root, propfile)
1698 with open(path, "r") as o:
1699 lines = o.readlines()
1703 with open(path, "w") as o:
1705 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1712 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1715 def reset_env_path():
1716 global env, orig_path
1717 env['PATH'] = orig_path
1720 def add_to_env_path(path):
1722 paths = env['PATH'].split(os.pathsep)
1726 env['PATH'] = os.pathsep.join(paths)
1729 def replace_config_vars(cmd, build):
1731 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1732 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1733 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1734 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1735 if build is not None:
1736 cmd = cmd.replace('$$COMMIT$$', build.commit)
1737 cmd = cmd.replace('$$VERSION$$', build.version)
1738 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1742 def place_srclib(root_dir, number, libpath):
1745 relpath = os.path.relpath(libpath, root_dir)
1746 proppath = os.path.join(root_dir, 'project.properties')
1749 if os.path.isfile(proppath):
1750 with open(proppath, "r") as o:
1751 lines = o.readlines()
1753 with open(proppath, "w") as o:
1756 if line.startswith('android.library.reference.%d=' % number):
1757 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1762 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1764 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1767 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1768 """Verify that two apks are the same
1770 One of the inputs is signed, the other is unsigned. The signature metadata
1771 is transferred from the signed to the unsigned apk, and then jarsigner is
1772 used to verify that the signature from the signed apk is also varlid for
1774 :param signed_apk: Path to a signed apk file
1775 :param unsigned_apk: Path to an unsigned apk file expected to match it
1776 :param tmp_dir: Path to directory for temporary files
1777 :returns: None if the verification is successful, otherwise a string
1778 describing what went wrong.
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 apk_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")
1798 apk_badchars = re.compile('''[/ :;'"]''')
1801 def compare_apks(apk1, apk2, tmp_dir):
1804 Returns None if the apk content is the same (apart from the signing key),
1805 otherwise a string describing what's different, or what went wrong when
1806 trying to do the comparison.
1809 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1810 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1811 for d in [apk1dir, apk2dir]:
1812 if os.path.exists(d):
1815 os.mkdir(os.path.join(d, 'jar-xf'))
1817 if subprocess.call(['jar', 'xf',
1818 os.path.abspath(apk1)],
1819 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1820 return("Failed to unpack " + apk1)
1821 if subprocess.call(['jar', 'xf',
1822 os.path.abspath(apk2)],
1823 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1824 return("Failed to unpack " + apk2)
1826 # try to find apktool in the path, if it hasn't been manually configed
1827 if 'apktool' not in config:
1828 tmp = find_command('apktool')
1830 config['apktool'] = tmp
1831 if 'apktool' in config:
1832 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1834 return("Failed to unpack " + apk1)
1835 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1837 return("Failed to unpack " + apk2)
1839 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1840 lines = p.output.splitlines()
1841 if len(lines) != 1 or 'META-INF' not in lines[0]:
1842 meld = find_command('meld')
1843 if meld is not None:
1844 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1845 return("Unexpected diff output - " + p.output)
1847 # since everything verifies, delete the comparison to keep cruft down
1848 shutil.rmtree(apk1dir)
1849 shutil.rmtree(apk2dir)
1851 # If we get here, it seems like they're the same!
1855 def find_command(command):
1856 '''find the full path of a command, or None if it can't be found in the PATH'''
1859 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1861 fpath, fname = os.path.split(command)
1866 for path in os.environ["PATH"].split(os.pathsep):
1867 path = path.strip('"')
1868 exe_file = os.path.join(path, command)
1869 if is_exe(exe_file):
1876 '''generate a random password for when generating keys'''
1877 h = hashlib.sha256()
1878 h.update(os.urandom(16)) # salt
1879 h.update(bytes(socket.getfqdn()))
1880 return h.digest().encode('base64').strip()
1883 def genkeystore(localconfig):
1884 '''Generate a new key with random passwords and add it to new keystore'''
1885 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1886 keystoredir = os.path.dirname(localconfig['keystore'])
1887 if keystoredir is None or keystoredir == '':
1888 keystoredir = os.path.join(os.getcwd(), keystoredir)
1889 if not os.path.exists(keystoredir):
1890 os.makedirs(keystoredir, mode=0o700)
1892 write_password_file("keystorepass", localconfig['keystorepass'])
1893 write_password_file("keypass", localconfig['keypass'])
1894 p = FDroidPopen(['keytool', '-genkey',
1895 '-keystore', localconfig['keystore'],
1896 '-alias', localconfig['repo_keyalias'],
1897 '-keyalg', 'RSA', '-keysize', '4096',
1898 '-sigalg', 'SHA256withRSA',
1899 '-validity', '10000',
1900 '-storepass:file', config['keystorepassfile'],
1901 '-keypass:file', config['keypassfile'],
1902 '-dname', localconfig['keydname']])
1903 # TODO keypass should be sent via stdin
1904 if p.returncode != 0:
1905 raise BuildException("Failed to generate key", p.output)
1906 os.chmod(localconfig['keystore'], 0o0600)
1907 # now show the lovely key that was just generated
1908 p = FDroidPopen(['keytool', '-list', '-v',
1909 '-keystore', localconfig['keystore'],
1910 '-alias', localconfig['repo_keyalias'],
1911 '-storepass:file', config['keystorepassfile']])
1912 logging.info(p.output.strip() + '\n\n')
1915 def write_to_config(thisconfig, key, value=None):
1916 '''write a key/value to the local config.py'''
1918 origkey = key + '_orig'
1919 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1920 with open('config.py', 'r') as f:
1922 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1923 repl = '\n' + key + ' = "' + value + '"'
1924 data = re.sub(pattern, repl, data)
1925 # if this key is not in the file, append it
1926 if not re.match('\s*' + key + '\s*=\s*"', data):
1928 # make sure the file ends with a carraige return
1929 if not re.match('\n$', data):
1931 with open('config.py', 'w') as f:
1935 def parse_xml(path):
1936 return XMLElementTree.parse(path).getroot()
1939 def string_is_integer(string):
1947 def get_per_app_repos():
1948 '''per-app repos are dirs named with the packageName of a single app'''
1950 # Android packageNames are Java packages, they may contain uppercase or
1951 # lowercase letters ('A' through 'Z'), numbers, and underscores
1952 # ('_'). However, individual package name parts may only start with
1953 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1954 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1957 for root, dirs, files in os.walk(os.getcwd()):
1959 print('checking', root, 'for', d)
1960 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1961 # standard parts of an fdroid repo, so never packageNames
1964 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):