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",
69 'accepted_formats': ['txt', 'yaml'],
70 'sync_from_local_copy_dir': False,
71 'per_app_repos': False,
72 'make_current_version_link': True,
73 'current_version_name_source': 'Name',
74 'update_stats': False,
78 'stats_to_carbon': False,
80 'build_server_always': False,
81 'keystore': 'keystore.jks',
82 'smartcardoptions': [],
88 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
89 'repo_name': "My First FDroid Repo Demo",
90 'repo_icon': "fdroid-icon.png",
91 'repo_description': '''
92 This is a repository of apps to be used with FDroid. Applications in this
93 repository are either official binaries built by the original application
94 developers, or are binaries built from source by the admin of f-droid.org
95 using the tools on https://gitlab.com/u/fdroid.
101 def setup_global_opts(parser):
102 parser.add_argument("-v", "--verbose", action="store_true", default=False,
103 help="Spew out even more information than normal")
104 parser.add_argument("-q", "--quiet", action="store_true", default=False,
105 help="Restrict output to warnings and errors")
108 def fill_config_defaults(thisconfig):
109 for k, v in default_config.items():
110 if k not in thisconfig:
113 # Expand paths (~users and $vars)
114 def expand_path(path):
118 path = os.path.expanduser(path)
119 path = os.path.expandvars(path)
124 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
129 thisconfig[k + '_orig'] = v
131 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
132 if thisconfig['java_paths'] is None:
133 thisconfig['java_paths'] = dict()
134 for d in sorted(glob.glob('/usr/lib/jvm/j*[6-9]*')
135 + glob.glob('/usr/java/jdk1.[6-9]*')
136 + glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
137 + glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')):
138 if os.path.islink(d):
140 j = os.path.basename(d)
141 # the last one found will be the canonical one, so order appropriately
142 for regex in (r'1\.([6-9])\.0\.jdk', # OSX
143 r'jdk1\.([6-9])\.0_[0-9]+.jdk', # OSX and Oracle tarball
144 r'jdk([6-9])-openjdk', # Arch
145 r'java-1\.([6-9])\.0-.*', # RedHat
146 r'java-([6-9])-oracle', # Debian WebUpd8
147 r'jdk-([6-9])-oracle-.*', # Debian make-jpkg
148 r'java-([6-9])-openjdk-[^c][^o][^m].*'): # Debian
149 m = re.match(regex, j)
151 osxhome = os.path.join(d, 'Contents', 'Home')
152 if os.path.exists(osxhome):
153 thisconfig['java_paths'][m.group(1)] = osxhome
155 thisconfig['java_paths'][m.group(1)] = d
157 for k in ['ndk_paths', 'java_paths']:
163 thisconfig[k][k2] = exp
164 thisconfig[k][k2 + '_orig'] = v
167 def regsub_file(pattern, repl, path):
168 with open(path, 'r') as f:
170 text = re.sub(pattern, repl, text)
171 with open(path, 'w') as f:
175 def read_config(opts, config_file='config.py'):
176 """Read the repository config
178 The config is read from config_file, which is in the current directory when
179 any of the repo management commands are used.
181 global config, options, env, orig_path
183 if config is not None:
185 if not os.path.isfile(config_file):
186 logging.critical("Missing config file - is this a repo directory?")
193 logging.debug("Reading %s" % config_file)
194 execfile(config_file, config)
196 # smartcardoptions must be a list since its command line args for Popen
197 if 'smartcardoptions' in config:
198 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
199 elif 'keystore' in config and config['keystore'] == 'NONE':
200 # keystore='NONE' means use smartcard, these are required defaults
201 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
202 'SunPKCS11-OpenSC', '-providerClass',
203 'sun.security.pkcs11.SunPKCS11',
204 '-providerArg', 'opensc-fdroid.cfg']
206 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
207 st = os.stat(config_file)
208 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
209 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
211 fill_config_defaults(config)
213 # There is no standard, so just set up the most common environment
216 orig_path = env['PATH']
217 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
218 env[n] = config['sdk_path']
220 for k, v in config['java_paths'].items():
221 env['JAVA%s_HOME' % k] = v
223 for k in ["keystorepass", "keypass"]:
225 write_password_file(k)
227 for k in ["repo_description", "archive_description"]:
229 config[k] = clean_description(config[k])
231 if 'serverwebroot' in config:
232 if isinstance(config['serverwebroot'], basestring):
233 roots = [config['serverwebroot']]
234 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
235 roots = config['serverwebroot']
237 raise TypeError('only accepts strings, lists, and tuples')
239 for rootstr in roots:
240 # since this is used with rsync, where trailing slashes have
241 # meaning, ensure there is always a trailing slash
242 if rootstr[-1] != '/':
244 rootlist.append(rootstr.replace('//', '/'))
245 config['serverwebroot'] = rootlist
250 def find_sdk_tools_cmd(cmd):
251 '''find a working path to a tool from the Android SDK'''
254 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
255 # try to find a working path to this command, in all the recent possible paths
256 if 'build_tools' in config:
257 build_tools = os.path.join(config['sdk_path'], 'build-tools')
258 # if 'build_tools' was manually set and exists, check only that one
259 configed_build_tools = os.path.join(build_tools, config['build_tools'])
260 if os.path.exists(configed_build_tools):
261 tooldirs.append(configed_build_tools)
263 # no configed version, so hunt known paths for it
264 for f in sorted(os.listdir(build_tools), reverse=True):
265 if os.path.isdir(os.path.join(build_tools, f)):
266 tooldirs.append(os.path.join(build_tools, f))
267 tooldirs.append(build_tools)
268 sdk_tools = os.path.join(config['sdk_path'], 'tools')
269 if os.path.exists(sdk_tools):
270 tooldirs.append(sdk_tools)
271 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
272 if os.path.exists(sdk_platform_tools):
273 tooldirs.append(sdk_platform_tools)
274 tooldirs.append('/usr/bin')
276 if os.path.isfile(os.path.join(d, cmd)):
277 return os.path.join(d, cmd)
278 # did not find the command, exit with error message
279 ensure_build_tools_exists(config)
282 def test_sdk_exists(thisconfig):
283 if 'sdk_path' not in thisconfig:
284 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
287 logging.error("'sdk_path' not set in config.py!")
289 if thisconfig['sdk_path'] == default_config['sdk_path']:
290 logging.error('No Android SDK found!')
291 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
292 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
294 if not os.path.exists(thisconfig['sdk_path']):
295 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
297 if not os.path.isdir(thisconfig['sdk_path']):
298 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
300 for d in ['build-tools', 'platform-tools', 'tools']:
301 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
302 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
303 thisconfig['sdk_path'], d))
308 def ensure_build_tools_exists(thisconfig):
309 if not test_sdk_exists(thisconfig):
311 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
312 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
313 if not os.path.isdir(versioned_build_tools):
314 logging.critical('Android Build Tools path "'
315 + versioned_build_tools + '" does not exist!')
319 def write_password_file(pwtype, password=None):
321 writes out passwords to a protected file instead of passing passwords as
322 command line argments
324 filename = '.fdroid.' + pwtype + '.txt'
325 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
327 os.write(fd, config[pwtype])
329 os.write(fd, password)
331 config[pwtype + 'file'] = filename
334 # Given the arguments in the form of multiple appid:[vc] strings, this returns
335 # a dictionary with the set of vercodes specified for each package.
336 def read_pkg_args(args, allow_vercodes=False):
343 if allow_vercodes and ':' in p:
344 package, vercode = p.split(':')
346 package, vercode = p, None
347 if package not in vercodes:
348 vercodes[package] = [vercode] if vercode else []
350 elif vercode and vercode not in vercodes[package]:
351 vercodes[package] += [vercode] if vercode else []
356 # On top of what read_pkg_args does, this returns the whole app metadata, but
357 # limiting the builds list to the builds matching the vercodes specified.
358 def read_app_args(args, allapps, allow_vercodes=False):
360 vercodes = read_pkg_args(args, allow_vercodes)
366 for appid, app in allapps.iteritems():
367 if appid in vercodes:
370 if len(apps) != len(vercodes):
373 logging.critical("No such package: %s" % p)
374 raise FDroidException("Found invalid app ids in arguments")
376 raise FDroidException("No packages specified")
379 for appid, app in apps.iteritems():
383 app.builds = [b for b in app.builds if b.vercode in vc]
384 if len(app.builds) != len(vercodes[appid]):
386 allvcs = [b.vercode for b in app.builds]
387 for v in vercodes[appid]:
389 logging.critical("No such vercode %s for app %s" % (v, appid))
392 raise FDroidException("Found invalid vercodes for some apps")
397 def get_extension(filename):
398 base, ext = os.path.splitext(filename)
401 return base, ext.lower()[1:]
404 def has_extension(filename, ext):
405 _, f_ext = get_extension(filename)
409 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
412 def clean_description(description):
413 'Remove unneeded newlines and spaces from a block of description text'
415 # this is split up by paragraph to make removing the newlines easier
416 for paragraph in re.split(r'\n\n', description):
417 paragraph = re.sub('\r', '', paragraph)
418 paragraph = re.sub('\n', ' ', paragraph)
419 paragraph = re.sub(' {2,}', ' ', paragraph)
420 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
421 returnstring += paragraph + '\n\n'
422 return returnstring.rstrip('\n')
425 def apknameinfo(filename):
426 filename = os.path.basename(filename)
427 m = apk_regex.match(filename)
429 result = (m.group(1), m.group(2))
430 except AttributeError:
431 raise FDroidException("Invalid apk name: %s" % filename)
435 def getapkname(app, build):
436 return "%s_%s.apk" % (app.id, build.vercode)
439 def getsrcname(app, build):
440 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
452 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
455 def getvcs(vcstype, remote, local):
457 return vcs_git(remote, local)
458 if vcstype == 'git-svn':
459 return vcs_gitsvn(remote, local)
461 return vcs_hg(remote, local)
463 return vcs_bzr(remote, local)
464 if vcstype == 'srclib':
465 if local != os.path.join('build', 'srclib', remote):
466 raise VCSException("Error: srclib paths are hard-coded!")
467 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
469 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
470 raise VCSException("Invalid vcs type " + vcstype)
473 def getsrclibvcs(name):
474 if name not in metadata.srclibs:
475 raise VCSException("Missing srclib " + name)
476 return metadata.srclibs[name]['Repo Type']
481 def __init__(self, remote, local):
483 # svn, git-svn and bzr may require auth
485 if self.repotype() in ('git-svn', 'bzr'):
487 if self.repotype == 'git-svn':
488 raise VCSException("Authentication is not supported for git-svn")
489 self.username, remote = remote.split('@')
490 if ':' not in self.username:
491 raise VCSException("Password required with username")
492 self.username, self.password = self.username.split(':')
496 self.clone_failed = False
497 self.refreshed = False
503 # Take the local repository to a clean version of the given revision, which
504 # is specificed in the VCS's native format. Beforehand, the repository can
505 # be dirty, or even non-existent. If the repository does already exist
506 # locally, it will be updated from the origin, but only once in the
507 # lifetime of the vcs object.
508 # None is acceptable for 'rev' if you know you are cloning a clean copy of
509 # the repo - otherwise it must specify a valid revision.
510 def gotorevision(self, rev, refresh=True):
512 if self.clone_failed:
513 raise VCSException("Downloading the repository already failed once, not trying again.")
515 # The .fdroidvcs-id file for a repo tells us what VCS type
516 # and remote that directory was created from, allowing us to drop it
517 # automatically if either of those things changes.
518 fdpath = os.path.join(self.local, '..',
519 '.fdroidvcs-' + os.path.basename(self.local))
520 cdata = self.repotype() + ' ' + self.remote
523 if os.path.exists(self.local):
524 if os.path.exists(fdpath):
525 with open(fdpath, 'r') as f:
526 fsdata = f.read().strip()
531 logging.info("Repository details for %s changed - deleting" % (
535 logging.info("Repository details for %s missing - deleting" % (
538 shutil.rmtree(self.local)
542 self.refreshed = True
545 self.gotorevisionx(rev)
546 except FDroidException as e:
549 # If necessary, write the .fdroidvcs file.
550 if writeback and not self.clone_failed:
551 with open(fdpath, 'w') as f:
557 # Derived classes need to implement this. It's called once basic checking
558 # has been performend.
559 def gotorevisionx(self, rev):
560 raise VCSException("This VCS type doesn't define gotorevisionx")
562 # Initialise and update submodules
563 def initsubmodules(self):
564 raise VCSException('Submodules not supported for this vcs type')
566 # Get a list of all known tags
568 if not self._gettags:
569 raise VCSException('gettags not supported for this vcs type')
571 for tag in self._gettags():
572 if re.match('[-A-Za-z0-9_. /]+$', tag):
576 def latesttags(self, tags, number):
577 """Get the most recent tags in a given list.
579 :param tags: a list of tags
580 :param number: the number to return
581 :returns: A list containing the most recent tags in the provided
582 list, up to the maximum number given.
584 raise VCSException('latesttags not supported for this vcs type')
586 # Get current commit reference (hash, revision, etc)
588 raise VCSException('getref not supported for this vcs type')
590 # Returns the srclib (name, path) used in setting up the current
601 # If the local directory exists, but is somehow not a git repository, git
602 # will traverse up the directory tree until it finds one that is (i.e.
603 # fdroidserver) and then we'll proceed to destroy it! This is called as
606 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
607 result = p.output.rstrip()
608 if not result.endswith(self.local):
609 raise VCSException('Repository mismatch')
611 def gotorevisionx(self, rev):
612 if not os.path.exists(self.local):
614 p = FDroidPopen(['git', 'clone', self.remote, self.local])
615 if p.returncode != 0:
616 self.clone_failed = True
617 raise VCSException("Git clone failed", p.output)
621 # Discard any working tree changes
622 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
623 'git', 'reset', '--hard'], cwd=self.local, output=False)
624 if p.returncode != 0:
625 raise VCSException("Git reset failed", p.output)
626 # Remove untracked files now, in case they're tracked in the target
627 # revision (it happens!)
628 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
629 'git', 'clean', '-dffx'], cwd=self.local, output=False)
630 if p.returncode != 0:
631 raise VCSException("Git clean failed", p.output)
632 if not self.refreshed:
633 # Get latest commits and tags from remote
634 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
635 if p.returncode != 0:
636 raise VCSException("Git fetch failed", p.output)
637 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
638 if p.returncode != 0:
639 raise VCSException("Git fetch failed", p.output)
640 # Recreate origin/HEAD as git clone would do it, in case it disappeared
641 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
642 if p.returncode != 0:
643 lines = p.output.splitlines()
644 if 'Multiple remote HEAD branches' not in lines[0]:
645 raise VCSException("Git remote set-head failed", p.output)
646 branch = lines[1].split(' ')[-1]
647 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
648 if p2.returncode != 0:
649 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
650 self.refreshed = True
651 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
652 # a github repo. Most of the time this is the same as origin/master.
653 rev = rev or 'origin/HEAD'
654 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
655 if p.returncode != 0:
656 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
657 # Get rid of any uncontrolled files left behind
658 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
659 if p.returncode != 0:
660 raise VCSException("Git clean failed", p.output)
662 def initsubmodules(self):
664 submfile = os.path.join(self.local, '.gitmodules')
665 if not os.path.isfile(submfile):
666 raise VCSException("No git submodules available")
668 # fix submodules not accessible without an account and public key auth
669 with open(submfile, 'r') as f:
670 lines = f.readlines()
671 with open(submfile, 'w') as f:
673 if 'git@github.com' in line:
674 line = line.replace('git@github.com:', 'https://github.com/')
675 if 'git@gitlab.com' in line:
676 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
679 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
680 if p.returncode != 0:
681 raise VCSException("Git submodule sync failed", p.output)
682 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
683 if p.returncode != 0:
684 raise VCSException("Git submodule update failed", p.output)
688 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
689 return p.output.splitlines()
691 def latesttags(self, tags, number):
696 ['git', 'show', '--format=format:%ct', '-s', tag],
697 cwd=self.local, output=False)
698 # Timestamp is on the last line. For a normal tag, it's the only
699 # line, but for annotated tags, the rest of the info precedes it.
700 ts = int(p.output.splitlines()[-1])
703 for _, t in sorted(tl)[-number:]:
708 class vcs_gitsvn(vcs):
713 # If the local directory exists, but is somehow not a git repository, git
714 # will traverse up the directory tree until it finds one that is (i.e.
715 # fdroidserver) and then we'll proceed to destory it! This is called as
718 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
719 result = p.output.rstrip()
720 if not result.endswith(self.local):
721 raise VCSException('Repository mismatch')
723 def gotorevisionx(self, rev):
724 if not os.path.exists(self.local):
726 gitsvn_args = ['git', 'svn', 'clone']
727 if ';' in self.remote:
728 remote_split = self.remote.split(';')
729 for i in remote_split[1:]:
730 if i.startswith('trunk='):
731 gitsvn_args.extend(['-T', i[6:]])
732 elif i.startswith('tags='):
733 gitsvn_args.extend(['-t', i[5:]])
734 elif i.startswith('branches='):
735 gitsvn_args.extend(['-b', i[9:]])
736 gitsvn_args.extend([remote_split[0], self.local])
737 p = FDroidPopen(gitsvn_args, output=False)
738 if p.returncode != 0:
739 self.clone_failed = True
740 raise VCSException("Git svn clone failed", p.output)
742 gitsvn_args.extend([self.remote, self.local])
743 p = FDroidPopen(gitsvn_args, output=False)
744 if p.returncode != 0:
745 self.clone_failed = True
746 raise VCSException("Git svn clone failed", p.output)
750 # Discard any working tree changes
751 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
752 if p.returncode != 0:
753 raise VCSException("Git reset failed", p.output)
754 # Remove untracked files now, in case they're tracked in the target
755 # revision (it happens!)
756 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
757 if p.returncode != 0:
758 raise VCSException("Git clean failed", p.output)
759 if not self.refreshed:
760 # Get new commits, branches and tags from repo
761 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
762 if p.returncode != 0:
763 raise VCSException("Git svn fetch failed")
764 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
765 if p.returncode != 0:
766 raise VCSException("Git svn rebase failed", p.output)
767 self.refreshed = True
769 rev = rev or 'master'
771 nospaces_rev = rev.replace(' ', '%20')
772 # Try finding a svn tag
773 for treeish in ['origin/', '']:
774 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
775 if p.returncode == 0:
777 if p.returncode != 0:
778 # No tag found, normal svn rev translation
779 # Translate svn rev into git format
780 rev_split = rev.split('/')
783 for treeish in ['origin/', '']:
784 if len(rev_split) > 1:
785 treeish += rev_split[0]
786 svn_rev = rev_split[1]
789 # if no branch is specified, then assume trunk (i.e. 'master' branch):
793 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
795 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
796 git_rev = p.output.rstrip()
798 if p.returncode == 0 and git_rev:
801 if p.returncode != 0 or not git_rev:
802 # Try a plain git checkout as a last resort
803 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
804 if p.returncode != 0:
805 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
807 # Check out the git rev equivalent to the svn rev
808 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
809 if p.returncode != 0:
810 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
812 # Get rid of any uncontrolled files left behind
813 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
814 if p.returncode != 0:
815 raise VCSException("Git clean failed", p.output)
819 for treeish in ['origin/', '']:
820 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
826 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
827 if p.returncode != 0:
829 return p.output.strip()
837 def gotorevisionx(self, rev):
838 if not os.path.exists(self.local):
839 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
840 if p.returncode != 0:
841 self.clone_failed = True
842 raise VCSException("Hg clone failed", p.output)
844 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
845 if p.returncode != 0:
846 raise VCSException("Hg status failed", p.output)
847 for line in p.output.splitlines():
848 if not line.startswith('? '):
849 raise VCSException("Unexpected output from hg status -uS: " + line)
850 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
851 if not self.refreshed:
852 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
853 if p.returncode != 0:
854 raise VCSException("Hg pull failed", p.output)
855 self.refreshed = True
857 rev = rev or 'default'
860 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
861 if p.returncode != 0:
862 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
863 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
864 # Also delete untracked files, we have to enable purge extension for that:
865 if "'purge' is provided by the following extension" in p.output:
866 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
867 myfile.write("\n[extensions]\nhgext.purge=\n")
868 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("HG purge failed", p.output)
871 elif p.returncode != 0:
872 raise VCSException("HG purge failed", p.output)
875 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
876 return p.output.splitlines()[1:]
884 def gotorevisionx(self, rev):
885 if not os.path.exists(self.local):
886 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
887 if p.returncode != 0:
888 self.clone_failed = True
889 raise VCSException("Bzr branch failed", p.output)
891 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
892 if p.returncode != 0:
893 raise VCSException("Bzr revert failed", p.output)
894 if not self.refreshed:
895 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
896 if p.returncode != 0:
897 raise VCSException("Bzr update failed", p.output)
898 self.refreshed = True
900 revargs = list(['-r', rev] if rev else [])
901 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
902 if p.returncode != 0:
903 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
906 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
907 return [tag.split(' ')[0].strip() for tag in
908 p.output.splitlines()]
911 def unescape_string(string):
914 if string[0] == '"' and string[-1] == '"':
917 return string.replace("\\'", "'")
920 def retrieve_string(app_dir, string, xmlfiles=None):
922 if not string.startswith('@string/'):
923 return unescape_string(string)
928 os.path.join(app_dir, 'res'),
929 os.path.join(app_dir, 'src', 'main', 'res'),
931 for r, d, f in os.walk(res_dir):
932 if os.path.basename(r) == 'values':
933 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
935 name = string[len('@string/'):]
937 def element_content(element):
938 if element.text is None:
940 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
943 for path in xmlfiles:
944 if not os.path.isfile(path):
946 xml = parse_xml(path)
947 element = xml.find('string[@name="' + name + '"]')
948 if element is not None:
949 content = element_content(element)
950 return retrieve_string(app_dir, content, xmlfiles)
955 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
956 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
959 # Return list of existing files that will be used to find the highest vercode
960 def manifest_paths(app_dir, flavours):
962 possible_manifests = \
963 [os.path.join(app_dir, 'AndroidManifest.xml'),
964 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
965 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
966 os.path.join(app_dir, 'build.gradle')]
968 for flavour in flavours:
971 possible_manifests.append(
972 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
974 return [path for path in possible_manifests if os.path.isfile(path)]
977 # Retrieve the package name. Returns the name, or None if not found.
978 def fetch_real_name(app_dir, flavours):
979 for path in manifest_paths(app_dir, flavours):
980 if not has_extension(path, 'xml') or not os.path.isfile(path):
982 logging.debug("fetch_real_name: Checking manifest at " + path)
983 xml = parse_xml(path)
984 app = xml.find('application')
987 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
989 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
990 result = retrieve_string_singleline(app_dir, label)
992 result = result.strip()
997 def get_library_references(root_dir):
999 proppath = os.path.join(root_dir, 'project.properties')
1000 if not os.path.isfile(proppath):
1002 for line in file(proppath):
1003 if not line.startswith('android.library.reference.'):
1005 path = line.split('=')[1].strip()
1006 relpath = os.path.join(root_dir, path)
1007 if not os.path.isdir(relpath):
1009 logging.debug("Found subproject at %s" % path)
1010 libraries.append(path)
1014 def ant_subprojects(root_dir):
1015 subprojects = get_library_references(root_dir)
1016 for subpath in subprojects:
1017 subrelpath = os.path.join(root_dir, subpath)
1018 for p in get_library_references(subrelpath):
1019 relp = os.path.normpath(os.path.join(subpath, p))
1020 if relp not in subprojects:
1021 subprojects.insert(0, relp)
1025 def remove_debuggable_flags(root_dir):
1026 # Remove forced debuggable flags
1027 logging.debug("Removing debuggable flags from %s" % root_dir)
1028 for root, dirs, files in os.walk(root_dir):
1029 if 'AndroidManifest.xml' in files:
1030 regsub_file(r'android:debuggable="[^"]*"',
1032 os.path.join(root, 'AndroidManifest.xml'))
1035 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1036 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1037 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1040 def app_matches_packagename(app, package):
1043 appid = app.UpdateCheckName or app.id
1044 if appid is None or appid == "Ignore":
1046 return appid == package
1049 # Extract some information from the AndroidManifest.xml at the given path.
1050 # Returns (version, vercode, package), any or all of which might be None.
1051 # All values returned are strings.
1052 def parse_androidmanifests(paths, app):
1054 ignoreversions = app.UpdateCheckIgnore
1055 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1058 return (None, None, None)
1066 if not os.path.isfile(path):
1069 logging.debug("Parsing manifest at {0}".format(path))
1070 gradle = has_extension(path, 'gradle')
1076 for line in file(path):
1077 if gradle_comment.match(line):
1079 # Grab first occurence of each to avoid running into
1080 # alternative flavours and builds.
1082 matches = psearch_g(line)
1084 s = matches.group(2)
1085 if app_matches_packagename(app, s):
1088 matches = vnsearch_g(line)
1090 version = matches.group(2)
1092 matches = vcsearch_g(line)
1094 vercode = matches.group(1)
1097 xml = parse_xml(path)
1098 if "package" in xml.attrib:
1099 s = xml.attrib["package"].encode('utf-8')
1100 if app_matches_packagename(app, s):
1102 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1103 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1104 base_dir = os.path.dirname(path)
1105 version = retrieve_string_singleline(base_dir, version)
1106 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1107 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1108 if string_is_integer(a):
1111 logging.warning("Problem with xml at {0}".format(path))
1113 # Remember package name, may be defined separately from version+vercode
1115 package = max_package
1117 logging.debug("..got package={0}, version={1}, vercode={2}"
1118 .format(package, version, vercode))
1120 # Always grab the package name and version name in case they are not
1121 # together with the highest version code
1122 if max_package is None and package is not None:
1123 max_package = package
1124 if max_version is None and version is not None:
1125 max_version = version
1127 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1128 if not ignoresearch or not ignoresearch(version):
1129 if version is not None:
1130 max_version = version
1131 if vercode is not None:
1132 max_vercode = vercode
1133 if package is not None:
1134 max_package = package
1136 max_version = "Ignore"
1138 if max_version is None:
1139 max_version = "Unknown"
1141 if max_package and not is_valid_package_name(max_package):
1142 raise FDroidException("Invalid package name {0}".format(max_package))
1144 return (max_version, max_vercode, max_package)
1147 def is_valid_package_name(name):
1148 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1151 class FDroidException(Exception):
1153 def __init__(self, value, detail=None):
1155 self.detail = detail
1157 def shortened_detail(self):
1158 if len(self.detail) < 16000:
1160 return '[...]\n' + self.detail[-16000:]
1162 def get_wikitext(self):
1163 ret = repr(self.value) + "\n"
1166 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1172 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1176 class VCSException(FDroidException):
1180 class BuildException(FDroidException):
1184 # Get the specified source library.
1185 # Returns the path to it. Normally this is the path to be used when referencing
1186 # it, which may be a subdirectory of the actual project. If you want the base
1187 # directory of the project, pass 'basepath=True'.
1188 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1189 raw=False, prepare=True, preponly=False, refresh=True):
1197 name, ref = spec.split('@')
1199 number, name = name.split(':', 1)
1201 name, subdir = name.split('/', 1)
1203 if name not in metadata.srclibs:
1204 raise VCSException('srclib ' + name + ' not found.')
1206 srclib = metadata.srclibs[name]
1208 sdir = os.path.join(srclib_dir, name)
1211 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1212 vcs.srclib = (name, number, sdir)
1214 vcs.gotorevision(ref, refresh)
1221 libdir = os.path.join(sdir, subdir)
1222 elif srclib["Subdir"]:
1223 for subdir in srclib["Subdir"]:
1224 libdir_candidate = os.path.join(sdir, subdir)
1225 if os.path.exists(libdir_candidate):
1226 libdir = libdir_candidate
1232 remove_signing_keys(sdir)
1233 remove_debuggable_flags(sdir)
1237 if srclib["Prepare"]:
1238 cmd = replace_config_vars(srclib["Prepare"], None)
1240 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1241 if p.returncode != 0:
1242 raise BuildException("Error running prepare command for srclib %s"
1248 return (name, number, libdir)
1250 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1253 # Prepare the source code for a particular build
1254 # 'vcs' - the appropriate vcs object for the application
1255 # 'app' - the application details from the metadata
1256 # 'build' - the build details from the metadata
1257 # 'build_dir' - the path to the build directory, usually
1259 # 'srclib_dir' - the path to the source libraries directory, usually
1261 # 'extlib_dir' - the path to the external libraries directory, usually
1263 # Returns the (root, srclibpaths) where:
1264 # 'root' is the root directory, which may be the same as 'build_dir' or may
1265 # be a subdirectory of it.
1266 # 'srclibpaths' is information on the srclibs being used
1267 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1269 # Optionally, the actual app source can be in a subdirectory
1271 root_dir = os.path.join(build_dir, build.subdir)
1273 root_dir = build_dir
1275 # Get a working copy of the right revision
1276 logging.info("Getting source for revision " + build.commit)
1277 vcs.gotorevision(build.commit, refresh)
1279 # Initialise submodules if required
1280 if build.submodules:
1281 logging.info("Initialising submodules")
1282 vcs.initsubmodules()
1284 # Check that a subdir (if we're using one) exists. This has to happen
1285 # after the checkout, since it might not exist elsewhere
1286 if not os.path.exists(root_dir):
1287 raise BuildException('Missing subdir ' + root_dir)
1289 # Run an init command if one is required
1291 cmd = replace_config_vars(build.init, build)
1292 logging.info("Running 'init' commands in %s" % root_dir)
1294 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1295 if p.returncode != 0:
1296 raise BuildException("Error running init command for %s:%s" %
1297 (app.id, build.version), p.output)
1299 # Apply patches if any
1301 logging.info("Applying patches")
1302 for patch in build.patch:
1303 patch = patch.strip()
1304 logging.info("Applying " + patch)
1305 patch_path = os.path.join('metadata', app.id, patch)
1306 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1307 if p.returncode != 0:
1308 raise BuildException("Failed to apply patch %s" % patch_path)
1310 # Get required source libraries
1313 logging.info("Collecting source libraries")
1314 for lib in build.srclibs:
1315 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1317 for name, number, libpath in srclibpaths:
1318 place_srclib(root_dir, int(number) if number else None, libpath)
1320 basesrclib = vcs.getsrclib()
1321 # If one was used for the main source, add that too.
1323 srclibpaths.append(basesrclib)
1325 # Update the local.properties file
1326 localprops = [os.path.join(build_dir, 'local.properties')]
1328 parts = build.subdir.split(os.sep)
1331 cur = os.path.join(cur, d)
1332 localprops += [os.path.join(cur, 'local.properties')]
1333 for path in localprops:
1335 if os.path.isfile(path):
1336 logging.info("Updating local.properties file at %s" % path)
1337 with open(path, 'r') as f:
1341 logging.info("Creating local.properties file at %s" % path)
1342 # Fix old-fashioned 'sdk-location' by copying
1343 # from sdk.dir, if necessary
1345 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1346 re.S | re.M).group(1)
1347 props += "sdk-location=%s\n" % sdkloc
1349 props += "sdk.dir=%s\n" % config['sdk_path']
1350 props += "sdk-location=%s\n" % config['sdk_path']
1351 ndk_path = build.ndk_path()
1354 props += "ndk.dir=%s\n" % ndk_path
1355 props += "ndk-location=%s\n" % ndk_path
1356 # Add java.encoding if necessary
1358 props += "java.encoding=%s\n" % build.encoding
1359 with open(path, 'w') as f:
1363 if build.method() == 'gradle':
1364 flavours = build.gradle
1367 n = build.target.split('-')[1]
1368 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1369 r'compileSdkVersion %s' % n,
1370 os.path.join(root_dir, 'build.gradle'))
1372 # Remove forced debuggable flags
1373 remove_debuggable_flags(root_dir)
1375 # Insert version code and number into the manifest if necessary
1376 if build.forceversion:
1377 logging.info("Changing the version name")
1378 for path in manifest_paths(root_dir, flavours):
1379 if not os.path.isfile(path):
1381 if has_extension(path, 'xml'):
1382 regsub_file(r'android:versionName="[^"]*"',
1383 r'android:versionName="%s"' % build.version,
1385 elif has_extension(path, 'gradle'):
1386 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1387 r"""\1versionName '%s'""" % build.version,
1390 if build.forcevercode:
1391 logging.info("Changing the version code")
1392 for path in manifest_paths(root_dir, flavours):
1393 if not os.path.isfile(path):
1395 if has_extension(path, 'xml'):
1396 regsub_file(r'android:versionCode="[^"]*"',
1397 r'android:versionCode="%s"' % build.vercode,
1399 elif has_extension(path, 'gradle'):
1400 regsub_file(r'versionCode[ =]+[0-9]+',
1401 r'versionCode %s' % build.vercode,
1404 # Delete unwanted files
1406 logging.info("Removing specified files")
1407 for part in getpaths(build_dir, build.rm):
1408 dest = os.path.join(build_dir, part)
1409 logging.info("Removing {0}".format(part))
1410 if os.path.lexists(dest):
1411 if os.path.islink(dest):
1412 FDroidPopen(['unlink', dest], output=False)
1414 FDroidPopen(['rm', '-rf', dest], output=False)
1416 logging.info("...but it didn't exist")
1418 remove_signing_keys(build_dir)
1420 # Add required external libraries
1422 logging.info("Collecting prebuilt libraries")
1423 libsdir = os.path.join(root_dir, 'libs')
1424 if not os.path.exists(libsdir):
1426 for lib in build.extlibs:
1428 logging.info("...installing extlib {0}".format(lib))
1429 libf = os.path.basename(lib)
1430 libsrc = os.path.join(extlib_dir, lib)
1431 if not os.path.exists(libsrc):
1432 raise BuildException("Missing extlib file {0}".format(libsrc))
1433 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1435 # Run a pre-build command if one is required
1437 logging.info("Running 'prebuild' commands in %s" % root_dir)
1439 cmd = replace_config_vars(build.prebuild, build)
1441 # Substitute source library paths into prebuild commands
1442 for name, number, libpath in srclibpaths:
1443 libpath = os.path.relpath(libpath, root_dir)
1444 cmd = cmd.replace('$$' + name + '$$', libpath)
1446 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1447 if p.returncode != 0:
1448 raise BuildException("Error running prebuild command for %s:%s" %
1449 (app.id, build.version), p.output)
1451 # Generate (or update) the ant build file, build.xml...
1452 if build.method() == 'ant' and build.update != ['no']:
1453 parms = ['android', 'update', 'lib-project']
1454 lparms = ['android', 'update', 'project']
1457 parms += ['-t', build.target]
1458 lparms += ['-t', build.target]
1460 update_dirs = build.update
1462 update_dirs = ant_subprojects(root_dir) + ['.']
1464 for d in update_dirs:
1465 subdir = os.path.join(root_dir, d)
1467 logging.debug("Updating main project")
1468 cmd = parms + ['-p', d]
1470 logging.debug("Updating subproject %s" % d)
1471 cmd = lparms + ['-p', d]
1472 p = SdkToolsPopen(cmd, cwd=root_dir)
1473 # Check to see whether an error was returned without a proper exit
1474 # code (this is the case for the 'no target set or target invalid'
1476 if p.returncode != 0 or p.output.startswith("Error: "):
1477 raise BuildException("Failed to update project at %s" % d, p.output)
1478 # Clean update dirs via ant
1480 logging.info("Cleaning subproject %s" % d)
1481 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1483 return (root_dir, srclibpaths)
1486 # Extend via globbing the paths from a field and return them as a map from
1487 # original path to resulting paths
1488 def getpaths_map(build_dir, globpaths):
1492 full_path = os.path.join(build_dir, p)
1493 full_path = os.path.normpath(full_path)
1494 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1496 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1500 # Extend via globbing the paths from a field and return them as a set
1501 def getpaths(build_dir, globpaths):
1502 paths_map = getpaths_map(build_dir, globpaths)
1504 for k, v in paths_map.iteritems():
1511 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1517 self.path = os.path.join('stats', 'known_apks.txt')
1519 if os.path.isfile(self.path):
1520 for line in file(self.path):
1521 t = line.rstrip().split(' ')
1523 self.apks[t[0]] = (t[1], None)
1525 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1526 self.changed = False
1528 def writeifchanged(self):
1529 if not self.changed:
1532 if not os.path.exists('stats'):
1536 for apk, app in self.apks.iteritems():
1538 line = apk + ' ' + appid
1540 line += ' ' + time.strftime('%Y-%m-%d', added)
1543 with open(self.path, 'w') as f:
1544 for line in sorted(lst, key=natural_key):
1545 f.write(line + '\n')
1547 # Record an apk (if it's new, otherwise does nothing)
1548 # Returns the date it was added.
1549 def recordapk(self, apk, app):
1550 if apk not in self.apks:
1551 self.apks[apk] = (app, time.gmtime(time.time()))
1553 _, added = self.apks[apk]
1556 # Look up information - given the 'apkname', returns (app id, date added/None).
1557 # Or returns None for an unknown apk.
1558 def getapp(self, apkname):
1559 if apkname in self.apks:
1560 return self.apks[apkname]
1563 # Get the most recent 'num' apps added to the repo, as a list of package ids
1564 # with the most recent first.
1565 def getlatest(self, num):
1567 for apk, app in self.apks.iteritems():
1571 if apps[appid] > added:
1575 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1576 lst = [app for app, _ in sortedapps]
1581 def isApkDebuggable(apkfile, config):
1582 """Returns True if the given apk file is debuggable
1584 :param apkfile: full path to the apk to check"""
1586 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1588 if p.returncode != 0:
1589 logging.critical("Failed to get apk manifest information")
1591 for line in p.output.splitlines():
1592 if 'android:debuggable' in line and not line.endswith('0x0'):
1602 def SdkToolsPopen(commands, cwd=None, output=True):
1604 if cmd not in config:
1605 config[cmd] = find_sdk_tools_cmd(commands[0])
1606 abscmd = config[cmd]
1608 logging.critical("Could not find '%s' on your system" % cmd)
1610 return FDroidPopen([abscmd] + commands[1:],
1611 cwd=cwd, output=output)
1614 def FDroidPopen(commands, cwd=None, output=True):
1616 Run a command and capture the possibly huge output.
1618 :param commands: command and argument list like in subprocess.Popen
1619 :param cwd: optionally specifies a working directory
1620 :returns: A PopenResult.
1626 cwd = os.path.normpath(cwd)
1627 logging.debug("Directory: %s" % cwd)
1628 logging.debug("> %s" % ' '.join(commands))
1630 result = PopenResult()
1633 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1634 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1635 except OSError as e:
1636 raise BuildException("OSError while trying to execute " +
1637 ' '.join(commands) + ': ' + str(e))
1639 stdout_queue = Queue()
1640 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1642 # Check the queue for output (until there is no more to get)
1643 while not stdout_reader.eof():
1644 while not stdout_queue.empty():
1645 line = stdout_queue.get()
1646 if output and options.verbose:
1647 # Output directly to console
1648 sys.stderr.write(line)
1650 result.output += line
1654 result.returncode = p.wait()
1658 gradle_comment = re.compile(r'[ ]*//')
1659 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1660 gradle_line_matches = [
1661 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1662 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1663 re.compile(r'.*\.readLine\(.*'),
1667 def remove_signing_keys(build_dir):
1668 for root, dirs, files in os.walk(build_dir):
1669 if 'build.gradle' in files:
1670 path = os.path.join(root, 'build.gradle')
1672 with open(path, "r") as o:
1673 lines = o.readlines()
1679 with open(path, "w") as o:
1680 while i < len(lines):
1683 while line.endswith('\\\n'):
1684 line = line.rstrip('\\\n') + lines[i]
1687 if gradle_comment.match(line):
1692 opened += line.count('{')
1693 opened -= line.count('}')
1696 if gradle_signing_configs.match(line):
1701 if any(s.match(line) for s in gradle_line_matches):
1709 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1712 'project.properties',
1714 'default.properties',
1715 'ant.properties', ]:
1716 if propfile in files:
1717 path = os.path.join(root, propfile)
1719 with open(path, "r") as o:
1720 lines = o.readlines()
1724 with open(path, "w") as o:
1726 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1733 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1736 def reset_env_path():
1737 global env, orig_path
1738 env['PATH'] = orig_path
1741 def add_to_env_path(path):
1743 paths = env['PATH'].split(os.pathsep)
1747 env['PATH'] = os.pathsep.join(paths)
1750 def replace_config_vars(cmd, build):
1752 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1753 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1754 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1755 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1756 if build is not None:
1757 cmd = cmd.replace('$$COMMIT$$', build.commit)
1758 cmd = cmd.replace('$$VERSION$$', build.version)
1759 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1763 def place_srclib(root_dir, number, libpath):
1766 relpath = os.path.relpath(libpath, root_dir)
1767 proppath = os.path.join(root_dir, 'project.properties')
1770 if os.path.isfile(proppath):
1771 with open(proppath, "r") as o:
1772 lines = o.readlines()
1774 with open(proppath, "w") as o:
1777 if line.startswith('android.library.reference.%d=' % number):
1778 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1783 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1785 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1788 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1789 """Verify that two apks are the same
1791 One of the inputs is signed, the other is unsigned. The signature metadata
1792 is transferred from the signed to the unsigned apk, and then jarsigner is
1793 used to verify that the signature from the signed apk is also varlid for
1795 :param signed_apk: Path to a signed apk file
1796 :param unsigned_apk: Path to an unsigned apk file expected to match it
1797 :param tmp_dir: Path to directory for temporary files
1798 :returns: None if the verification is successful, otherwise a string
1799 describing what went wrong.
1801 with ZipFile(signed_apk) as signed_apk_as_zip:
1802 meta_inf_files = ['META-INF/MANIFEST.MF']
1803 for f in signed_apk_as_zip.namelist():
1804 if apk_sigfile.match(f):
1805 meta_inf_files.append(f)
1806 if len(meta_inf_files) < 3:
1807 return "Signature files missing from {0}".format(signed_apk)
1808 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1809 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1810 for meta_inf_file in meta_inf_files:
1811 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1813 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1814 logging.info("...NOT verified - {0}".format(signed_apk))
1815 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1816 logging.info("...successfully verified")
1819 apk_badchars = re.compile('''[/ :;'"]''')
1822 def compare_apks(apk1, apk2, tmp_dir):
1825 Returns None if the apk content is the same (apart from the signing key),
1826 otherwise a string describing what's different, or what went wrong when
1827 trying to do the comparison.
1830 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1831 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1832 for d in [apk1dir, apk2dir]:
1833 if os.path.exists(d):
1836 os.mkdir(os.path.join(d, 'jar-xf'))
1838 if subprocess.call(['jar', 'xf',
1839 os.path.abspath(apk1)],
1840 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1841 return("Failed to unpack " + apk1)
1842 if subprocess.call(['jar', 'xf',
1843 os.path.abspath(apk2)],
1844 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1845 return("Failed to unpack " + apk2)
1847 # try to find apktool in the path, if it hasn't been manually configed
1848 if 'apktool' not in config:
1849 tmp = find_command('apktool')
1851 config['apktool'] = tmp
1852 if 'apktool' in config:
1853 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1855 return("Failed to unpack " + apk1)
1856 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1858 return("Failed to unpack " + apk2)
1860 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1861 lines = p.output.splitlines()
1862 if len(lines) != 1 or 'META-INF' not in lines[0]:
1863 meld = find_command('meld')
1864 if meld is not None:
1865 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1866 return("Unexpected diff output - " + p.output)
1868 # since everything verifies, delete the comparison to keep cruft down
1869 shutil.rmtree(apk1dir)
1870 shutil.rmtree(apk2dir)
1872 # If we get here, it seems like they're the same!
1876 def find_command(command):
1877 '''find the full path of a command, or None if it can't be found in the PATH'''
1880 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1882 fpath, fname = os.path.split(command)
1887 for path in os.environ["PATH"].split(os.pathsep):
1888 path = path.strip('"')
1889 exe_file = os.path.join(path, command)
1890 if is_exe(exe_file):
1897 '''generate a random password for when generating keys'''
1898 h = hashlib.sha256()
1899 h.update(os.urandom(16)) # salt
1900 h.update(bytes(socket.getfqdn()))
1901 return h.digest().encode('base64').strip()
1904 def genkeystore(localconfig):
1905 '''Generate a new key with random passwords and add it to new keystore'''
1906 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1907 keystoredir = os.path.dirname(localconfig['keystore'])
1908 if keystoredir is None or keystoredir == '':
1909 keystoredir = os.path.join(os.getcwd(), keystoredir)
1910 if not os.path.exists(keystoredir):
1911 os.makedirs(keystoredir, mode=0o700)
1913 write_password_file("keystorepass", localconfig['keystorepass'])
1914 write_password_file("keypass", localconfig['keypass'])
1915 p = FDroidPopen(['keytool', '-genkey',
1916 '-keystore', localconfig['keystore'],
1917 '-alias', localconfig['repo_keyalias'],
1918 '-keyalg', 'RSA', '-keysize', '4096',
1919 '-sigalg', 'SHA256withRSA',
1920 '-validity', '10000',
1921 '-storepass:file', config['keystorepassfile'],
1922 '-keypass:file', config['keypassfile'],
1923 '-dname', localconfig['keydname']])
1924 # TODO keypass should be sent via stdin
1925 if p.returncode != 0:
1926 raise BuildException("Failed to generate key", p.output)
1927 os.chmod(localconfig['keystore'], 0o0600)
1928 # now show the lovely key that was just generated
1929 p = FDroidPopen(['keytool', '-list', '-v',
1930 '-keystore', localconfig['keystore'],
1931 '-alias', localconfig['repo_keyalias'],
1932 '-storepass:file', config['keystorepassfile']])
1933 logging.info(p.output.strip() + '\n\n')
1936 def write_to_config(thisconfig, key, value=None):
1937 '''write a key/value to the local config.py'''
1939 origkey = key + '_orig'
1940 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1941 with open('config.py', 'r') as f:
1943 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1944 repl = '\n' + key + ' = "' + value + '"'
1945 data = re.sub(pattern, repl, data)
1946 # if this key is not in the file, append it
1947 if not re.match('\s*' + key + '\s*=\s*"', data):
1949 # make sure the file ends with a carraige return
1950 if not re.match('\n$', data):
1952 with open('config.py', 'w') as f:
1956 def parse_xml(path):
1957 return XMLElementTree.parse(path).getroot()
1960 def string_is_integer(string):
1968 def get_per_app_repos():
1969 '''per-app repos are dirs named with the packageName of a single app'''
1971 # Android packageNames are Java packages, they may contain uppercase or
1972 # lowercase letters ('A' through 'Z'), numbers, and underscores
1973 # ('_'). However, individual package name parts may only start with
1974 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1975 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1978 for root, dirs, files in os.walk(os.getcwd()):
1980 print('checking', root, 'for', d)
1981 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1982 # standard parts of an fdroid repo, so never packageNames
1985 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):