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-([6-9])-openjdk', # Arch
146 r'java-([6-9])-jdk', # Arch (oracle)
147 r'java-1\.([6-9])\.0-.*', # RedHat
148 r'java-([6-9])-oracle', # Debian WebUpd8
149 r'jdk-([6-9])-oracle-.*', # Debian make-jpkg
150 r'java-([6-9])-openjdk-[^c][^o][^m].*'): # Debian
151 m = re.match(regex, j)
153 osxhome = os.path.join(d, 'Contents', 'Home')
154 if os.path.exists(osxhome):
155 thisconfig['java_paths'][m.group(1)] = osxhome
157 thisconfig['java_paths'][m.group(1)] = d
159 for java_version in ('7', '8', '9'):
160 java_home = thisconfig['java_paths'][java_version]
161 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
162 if os.path.exists(jarsigner):
163 thisconfig['jarsigner'] = jarsigner
164 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
165 break # Java7 is preferred, so quit if found
167 for k in ['ndk_paths', 'java_paths']:
173 thisconfig[k][k2] = exp
174 thisconfig[k][k2 + '_orig'] = v
177 def regsub_file(pattern, repl, path):
178 with open(path, 'r') as f:
180 text = re.sub(pattern, repl, text)
181 with open(path, 'w') as f:
185 def read_config(opts, config_file='config.py'):
186 """Read the repository config
188 The config is read from config_file, which is in the current directory when
189 any of the repo management commands are used.
191 global config, options, env, orig_path
193 if config is not None:
195 if not os.path.isfile(config_file):
196 logging.critical("Missing config file - is this a repo directory?")
203 logging.debug("Reading %s" % config_file)
204 execfile(config_file, config)
206 # smartcardoptions must be a list since its command line args for Popen
207 if 'smartcardoptions' in config:
208 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
209 elif 'keystore' in config and config['keystore'] == 'NONE':
210 # keystore='NONE' means use smartcard, these are required defaults
211 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
212 'SunPKCS11-OpenSC', '-providerClass',
213 'sun.security.pkcs11.SunPKCS11',
214 '-providerArg', 'opensc-fdroid.cfg']
216 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
217 st = os.stat(config_file)
218 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
219 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
221 fill_config_defaults(config)
223 # There is no standard, so just set up the most common environment
226 orig_path = env['PATH']
227 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
228 env[n] = config['sdk_path']
230 for k, v in config['java_paths'].items():
231 env['JAVA%s_HOME' % k] = v
233 for k in ["keystorepass", "keypass"]:
235 write_password_file(k)
237 for k in ["repo_description", "archive_description"]:
239 config[k] = clean_description(config[k])
241 if 'serverwebroot' in config:
242 if isinstance(config['serverwebroot'], basestring):
243 roots = [config['serverwebroot']]
244 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
245 roots = config['serverwebroot']
247 raise TypeError('only accepts strings, lists, and tuples')
249 for rootstr in roots:
250 # since this is used with rsync, where trailing slashes have
251 # meaning, ensure there is always a trailing slash
252 if rootstr[-1] != '/':
254 rootlist.append(rootstr.replace('//', '/'))
255 config['serverwebroot'] = rootlist
260 def find_sdk_tools_cmd(cmd):
261 '''find a working path to a tool from the Android SDK'''
264 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
265 # try to find a working path to this command, in all the recent possible paths
266 if 'build_tools' in config:
267 build_tools = os.path.join(config['sdk_path'], 'build-tools')
268 # if 'build_tools' was manually set and exists, check only that one
269 configed_build_tools = os.path.join(build_tools, config['build_tools'])
270 if os.path.exists(configed_build_tools):
271 tooldirs.append(configed_build_tools)
273 # no configed version, so hunt known paths for it
274 for f in sorted(os.listdir(build_tools), reverse=True):
275 if os.path.isdir(os.path.join(build_tools, f)):
276 tooldirs.append(os.path.join(build_tools, f))
277 tooldirs.append(build_tools)
278 sdk_tools = os.path.join(config['sdk_path'], 'tools')
279 if os.path.exists(sdk_tools):
280 tooldirs.append(sdk_tools)
281 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
282 if os.path.exists(sdk_platform_tools):
283 tooldirs.append(sdk_platform_tools)
284 tooldirs.append('/usr/bin')
286 if os.path.isfile(os.path.join(d, cmd)):
287 return os.path.join(d, cmd)
288 # did not find the command, exit with error message
289 ensure_build_tools_exists(config)
292 def test_sdk_exists(thisconfig):
293 if 'sdk_path' not in thisconfig:
294 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
297 logging.error("'sdk_path' not set in config.py!")
299 if thisconfig['sdk_path'] == default_config['sdk_path']:
300 logging.error('No Android SDK found!')
301 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
302 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
304 if not os.path.exists(thisconfig['sdk_path']):
305 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
307 if not os.path.isdir(thisconfig['sdk_path']):
308 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
310 for d in ['build-tools', 'platform-tools', 'tools']:
311 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
312 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
313 thisconfig['sdk_path'], d))
318 def ensure_build_tools_exists(thisconfig):
319 if not test_sdk_exists(thisconfig):
321 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
322 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
323 if not os.path.isdir(versioned_build_tools):
324 logging.critical('Android Build Tools path "'
325 + versioned_build_tools + '" does not exist!')
329 def write_password_file(pwtype, password=None):
331 writes out passwords to a protected file instead of passing passwords as
332 command line argments
334 filename = '.fdroid.' + pwtype + '.txt'
335 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
337 os.write(fd, config[pwtype])
339 os.write(fd, password)
341 config[pwtype + 'file'] = filename
344 # Given the arguments in the form of multiple appid:[vc] strings, this returns
345 # a dictionary with the set of vercodes specified for each package.
346 def read_pkg_args(args, allow_vercodes=False):
353 if allow_vercodes and ':' in p:
354 package, vercode = p.split(':')
356 package, vercode = p, None
357 if package not in vercodes:
358 vercodes[package] = [vercode] if vercode else []
360 elif vercode and vercode not in vercodes[package]:
361 vercodes[package] += [vercode] if vercode else []
366 # On top of what read_pkg_args does, this returns the whole app metadata, but
367 # limiting the builds list to the builds matching the vercodes specified.
368 def read_app_args(args, allapps, allow_vercodes=False):
370 vercodes = read_pkg_args(args, allow_vercodes)
376 for appid, app in allapps.iteritems():
377 if appid in vercodes:
380 if len(apps) != len(vercodes):
383 logging.critical("No such package: %s" % p)
384 raise FDroidException("Found invalid app ids in arguments")
386 raise FDroidException("No packages specified")
389 for appid, app in apps.iteritems():
393 app.builds = [b for b in app.builds if b.vercode in vc]
394 if len(app.builds) != len(vercodes[appid]):
396 allvcs = [b.vercode for b in app.builds]
397 for v in vercodes[appid]:
399 logging.critical("No such vercode %s for app %s" % (v, appid))
402 raise FDroidException("Found invalid vercodes for some apps")
407 def get_extension(filename):
408 base, ext = os.path.splitext(filename)
411 return base, ext.lower()[1:]
414 def has_extension(filename, ext):
415 _, f_ext = get_extension(filename)
419 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
422 def clean_description(description):
423 'Remove unneeded newlines and spaces from a block of description text'
425 # this is split up by paragraph to make removing the newlines easier
426 for paragraph in re.split(r'\n\n', description):
427 paragraph = re.sub('\r', '', paragraph)
428 paragraph = re.sub('\n', ' ', paragraph)
429 paragraph = re.sub(' {2,}', ' ', paragraph)
430 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
431 returnstring += paragraph + '\n\n'
432 return returnstring.rstrip('\n')
435 def apknameinfo(filename):
436 filename = os.path.basename(filename)
437 m = apk_regex.match(filename)
439 result = (m.group(1), m.group(2))
440 except AttributeError:
441 raise FDroidException("Invalid apk name: %s" % filename)
445 def getapkname(app, build):
446 return "%s_%s.apk" % (app.id, build.vercode)
449 def getsrcname(app, build):
450 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
462 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
465 def getvcs(vcstype, remote, local):
467 return vcs_git(remote, local)
468 if vcstype == 'git-svn':
469 return vcs_gitsvn(remote, local)
471 return vcs_hg(remote, local)
473 return vcs_bzr(remote, local)
474 if vcstype == 'srclib':
475 if local != os.path.join('build', 'srclib', remote):
476 raise VCSException("Error: srclib paths are hard-coded!")
477 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
479 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
480 raise VCSException("Invalid vcs type " + vcstype)
483 def getsrclibvcs(name):
484 if name not in metadata.srclibs:
485 raise VCSException("Missing srclib " + name)
486 return metadata.srclibs[name]['Repo Type']
491 def __init__(self, remote, local):
493 # svn, git-svn and bzr may require auth
495 if self.repotype() in ('git-svn', 'bzr'):
497 if self.repotype == 'git-svn':
498 raise VCSException("Authentication is not supported for git-svn")
499 self.username, remote = remote.split('@')
500 if ':' not in self.username:
501 raise VCSException("Password required with username")
502 self.username, self.password = self.username.split(':')
506 self.clone_failed = False
507 self.refreshed = False
513 # Take the local repository to a clean version of the given revision, which
514 # is specificed in the VCS's native format. Beforehand, the repository can
515 # be dirty, or even non-existent. If the repository does already exist
516 # locally, it will be updated from the origin, but only once in the
517 # lifetime of the vcs object.
518 # None is acceptable for 'rev' if you know you are cloning a clean copy of
519 # the repo - otherwise it must specify a valid revision.
520 def gotorevision(self, rev, refresh=True):
522 if self.clone_failed:
523 raise VCSException("Downloading the repository already failed once, not trying again.")
525 # The .fdroidvcs-id file for a repo tells us what VCS type
526 # and remote that directory was created from, allowing us to drop it
527 # automatically if either of those things changes.
528 fdpath = os.path.join(self.local, '..',
529 '.fdroidvcs-' + os.path.basename(self.local))
530 cdata = self.repotype() + ' ' + self.remote
533 if os.path.exists(self.local):
534 if os.path.exists(fdpath):
535 with open(fdpath, 'r') as f:
536 fsdata = f.read().strip()
541 logging.info("Repository details for %s changed - deleting" % (
545 logging.info("Repository details for %s missing - deleting" % (
548 shutil.rmtree(self.local)
552 self.refreshed = True
555 self.gotorevisionx(rev)
556 except FDroidException as e:
559 # If necessary, write the .fdroidvcs file.
560 if writeback and not self.clone_failed:
561 with open(fdpath, 'w') as f:
567 # Derived classes need to implement this. It's called once basic checking
568 # has been performend.
569 def gotorevisionx(self, rev):
570 raise VCSException("This VCS type doesn't define gotorevisionx")
572 # Initialise and update submodules
573 def initsubmodules(self):
574 raise VCSException('Submodules not supported for this vcs type')
576 # Get a list of all known tags
578 if not self._gettags:
579 raise VCSException('gettags not supported for this vcs type')
581 for tag in self._gettags():
582 if re.match('[-A-Za-z0-9_. /]+$', tag):
586 def latesttags(self, tags, number):
587 """Get the most recent tags in a given list.
589 :param tags: a list of tags
590 :param number: the number to return
591 :returns: A list containing the most recent tags in the provided
592 list, up to the maximum number given.
594 raise VCSException('latesttags not supported for this vcs type')
596 # Get current commit reference (hash, revision, etc)
598 raise VCSException('getref not supported for this vcs type')
600 # Returns the srclib (name, path) used in setting up the current
611 # If the local directory exists, but is somehow not a git repository, git
612 # will traverse up the directory tree until it finds one that is (i.e.
613 # fdroidserver) and then we'll proceed to destroy it! This is called as
616 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
617 result = p.output.rstrip()
618 if not result.endswith(self.local):
619 raise VCSException('Repository mismatch')
621 def gotorevisionx(self, rev):
622 if not os.path.exists(self.local):
624 p = FDroidPopen(['git', 'clone', self.remote, self.local])
625 if p.returncode != 0:
626 self.clone_failed = True
627 raise VCSException("Git clone failed", p.output)
631 # Discard any working tree changes
632 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
633 'git', 'reset', '--hard'], cwd=self.local, output=False)
634 if p.returncode != 0:
635 raise VCSException("Git reset failed", p.output)
636 # Remove untracked files now, in case they're tracked in the target
637 # revision (it happens!)
638 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
639 'git', 'clean', '-dffx'], cwd=self.local, output=False)
640 if p.returncode != 0:
641 raise VCSException("Git clean failed", p.output)
642 if not self.refreshed:
643 # Get latest commits and tags from remote
644 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
645 if p.returncode != 0:
646 raise VCSException("Git fetch failed", p.output)
647 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
648 if p.returncode != 0:
649 raise VCSException("Git fetch failed", p.output)
650 # Recreate origin/HEAD as git clone would do it, in case it disappeared
651 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
652 if p.returncode != 0:
653 lines = p.output.splitlines()
654 if 'Multiple remote HEAD branches' not in lines[0]:
655 raise VCSException("Git remote set-head failed", p.output)
656 branch = lines[1].split(' ')[-1]
657 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
658 if p2.returncode != 0:
659 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
660 self.refreshed = True
661 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
662 # a github repo. Most of the time this is the same as origin/master.
663 rev = rev or 'origin/HEAD'
664 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
665 if p.returncode != 0:
666 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
667 # Get rid of any uncontrolled files left behind
668 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
669 if p.returncode != 0:
670 raise VCSException("Git clean failed", p.output)
672 def initsubmodules(self):
674 submfile = os.path.join(self.local, '.gitmodules')
675 if not os.path.isfile(submfile):
676 raise VCSException("No git submodules available")
678 # fix submodules not accessible without an account and public key auth
679 with open(submfile, 'r') as f:
680 lines = f.readlines()
681 with open(submfile, 'w') as f:
683 if 'git@github.com' in line:
684 line = line.replace('git@github.com:', 'https://github.com/')
685 if 'git@gitlab.com' in line:
686 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
689 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
690 if p.returncode != 0:
691 raise VCSException("Git submodule sync failed", p.output)
692 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
693 if p.returncode != 0:
694 raise VCSException("Git submodule update failed", p.output)
698 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
699 return p.output.splitlines()
701 def latesttags(self, tags, number):
706 ['git', 'show', '--format=format:%ct', '-s', tag],
707 cwd=self.local, output=False)
708 # Timestamp is on the last line. For a normal tag, it's the only
709 # line, but for annotated tags, the rest of the info precedes it.
710 ts = int(p.output.splitlines()[-1])
713 for _, t in sorted(tl)[-number:]:
718 class vcs_gitsvn(vcs):
723 # If the local directory exists, but is somehow not a git repository, git
724 # will traverse up the directory tree until it finds one that is (i.e.
725 # fdroidserver) and then we'll proceed to destory it! This is called as
728 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
729 result = p.output.rstrip()
730 if not result.endswith(self.local):
731 raise VCSException('Repository mismatch')
733 def gotorevisionx(self, rev):
734 if not os.path.exists(self.local):
736 gitsvn_args = ['git', 'svn', 'clone']
737 if ';' in self.remote:
738 remote_split = self.remote.split(';')
739 for i in remote_split[1:]:
740 if i.startswith('trunk='):
741 gitsvn_args.extend(['-T', i[6:]])
742 elif i.startswith('tags='):
743 gitsvn_args.extend(['-t', i[5:]])
744 elif i.startswith('branches='):
745 gitsvn_args.extend(['-b', i[9:]])
746 gitsvn_args.extend([remote_split[0], self.local])
747 p = FDroidPopen(gitsvn_args, output=False)
748 if p.returncode != 0:
749 self.clone_failed = True
750 raise VCSException("Git svn clone failed", p.output)
752 gitsvn_args.extend([self.remote, self.local])
753 p = FDroidPopen(gitsvn_args, output=False)
754 if p.returncode != 0:
755 self.clone_failed = True
756 raise VCSException("Git svn clone failed", p.output)
760 # Discard any working tree changes
761 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
762 if p.returncode != 0:
763 raise VCSException("Git reset failed", p.output)
764 # Remove untracked files now, in case they're tracked in the target
765 # revision (it happens!)
766 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
767 if p.returncode != 0:
768 raise VCSException("Git clean failed", p.output)
769 if not self.refreshed:
770 # Get new commits, branches and tags from repo
771 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
772 if p.returncode != 0:
773 raise VCSException("Git svn fetch failed")
774 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
775 if p.returncode != 0:
776 raise VCSException("Git svn rebase failed", p.output)
777 self.refreshed = True
779 rev = rev or 'master'
781 nospaces_rev = rev.replace(' ', '%20')
782 # Try finding a svn tag
783 for treeish in ['origin/', '']:
784 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
785 if p.returncode == 0:
787 if p.returncode != 0:
788 # No tag found, normal svn rev translation
789 # Translate svn rev into git format
790 rev_split = rev.split('/')
793 for treeish in ['origin/', '']:
794 if len(rev_split) > 1:
795 treeish += rev_split[0]
796 svn_rev = rev_split[1]
799 # if no branch is specified, then assume trunk (i.e. 'master' branch):
803 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
805 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
806 git_rev = p.output.rstrip()
808 if p.returncode == 0 and git_rev:
811 if p.returncode != 0 or not git_rev:
812 # Try a plain git checkout as a last resort
813 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
814 if p.returncode != 0:
815 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
817 # Check out the git rev equivalent to the svn rev
818 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
819 if p.returncode != 0:
820 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
822 # Get rid of any uncontrolled files left behind
823 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
824 if p.returncode != 0:
825 raise VCSException("Git clean failed", p.output)
829 for treeish in ['origin/', '']:
830 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
836 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
837 if p.returncode != 0:
839 return p.output.strip()
847 def gotorevisionx(self, rev):
848 if not os.path.exists(self.local):
849 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
850 if p.returncode != 0:
851 self.clone_failed = True
852 raise VCSException("Hg clone failed", p.output)
854 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
855 if p.returncode != 0:
856 raise VCSException("Hg status failed", p.output)
857 for line in p.output.splitlines():
858 if not line.startswith('? '):
859 raise VCSException("Unexpected output from hg status -uS: " + line)
860 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
861 if not self.refreshed:
862 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
863 if p.returncode != 0:
864 raise VCSException("Hg pull failed", p.output)
865 self.refreshed = True
867 rev = rev or 'default'
870 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
873 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
874 # Also delete untracked files, we have to enable purge extension for that:
875 if "'purge' is provided by the following extension" in p.output:
876 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
877 myfile.write("\n[extensions]\nhgext.purge=\n")
878 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
879 if p.returncode != 0:
880 raise VCSException("HG purge failed", p.output)
881 elif p.returncode != 0:
882 raise VCSException("HG purge failed", p.output)
885 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
886 return p.output.splitlines()[1:]
894 def gotorevisionx(self, rev):
895 if not os.path.exists(self.local):
896 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
897 if p.returncode != 0:
898 self.clone_failed = True
899 raise VCSException("Bzr branch failed", p.output)
901 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
902 if p.returncode != 0:
903 raise VCSException("Bzr revert failed", p.output)
904 if not self.refreshed:
905 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
906 if p.returncode != 0:
907 raise VCSException("Bzr update failed", p.output)
908 self.refreshed = True
910 revargs = list(['-r', rev] if rev else [])
911 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
912 if p.returncode != 0:
913 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
916 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
917 return [tag.split(' ')[0].strip() for tag in
918 p.output.splitlines()]
921 def unescape_string(string):
924 if string[0] == '"' and string[-1] == '"':
927 return string.replace("\\'", "'")
930 def retrieve_string(app_dir, string, xmlfiles=None):
932 if not string.startswith('@string/'):
933 return unescape_string(string)
938 os.path.join(app_dir, 'res'),
939 os.path.join(app_dir, 'src', 'main', 'res'),
941 for r, d, f in os.walk(res_dir):
942 if os.path.basename(r) == 'values':
943 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
945 name = string[len('@string/'):]
947 def element_content(element):
948 if element.text is None:
950 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
953 for path in xmlfiles:
954 if not os.path.isfile(path):
956 xml = parse_xml(path)
957 element = xml.find('string[@name="' + name + '"]')
958 if element is not None:
959 content = element_content(element)
960 return retrieve_string(app_dir, content, xmlfiles)
965 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
966 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
969 # Return list of existing files that will be used to find the highest vercode
970 def manifest_paths(app_dir, flavours):
972 possible_manifests = \
973 [os.path.join(app_dir, 'AndroidManifest.xml'),
974 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
975 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
976 os.path.join(app_dir, 'build.gradle')]
978 for flavour in flavours:
981 possible_manifests.append(
982 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
984 return [path for path in possible_manifests if os.path.isfile(path)]
987 # Retrieve the package name. Returns the name, or None if not found.
988 def fetch_real_name(app_dir, flavours):
989 for path in manifest_paths(app_dir, flavours):
990 if not has_extension(path, 'xml') or not os.path.isfile(path):
992 logging.debug("fetch_real_name: Checking manifest at " + path)
993 xml = parse_xml(path)
994 app = xml.find('application')
997 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
999 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
1000 result = retrieve_string_singleline(app_dir, label)
1002 result = result.strip()
1007 def get_library_references(root_dir):
1009 proppath = os.path.join(root_dir, 'project.properties')
1010 if not os.path.isfile(proppath):
1012 for line in file(proppath):
1013 if not line.startswith('android.library.reference.'):
1015 path = line.split('=')[1].strip()
1016 relpath = os.path.join(root_dir, path)
1017 if not os.path.isdir(relpath):
1019 logging.debug("Found subproject at %s" % path)
1020 libraries.append(path)
1024 def ant_subprojects(root_dir):
1025 subprojects = get_library_references(root_dir)
1026 for subpath in subprojects:
1027 subrelpath = os.path.join(root_dir, subpath)
1028 for p in get_library_references(subrelpath):
1029 relp = os.path.normpath(os.path.join(subpath, p))
1030 if relp not in subprojects:
1031 subprojects.insert(0, relp)
1035 def remove_debuggable_flags(root_dir):
1036 # Remove forced debuggable flags
1037 logging.debug("Removing debuggable flags from %s" % root_dir)
1038 for root, dirs, files in os.walk(root_dir):
1039 if 'AndroidManifest.xml' in files:
1040 regsub_file(r'android:debuggable="[^"]*"',
1042 os.path.join(root, 'AndroidManifest.xml'))
1045 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1046 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1047 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1050 def app_matches_packagename(app, package):
1053 appid = app.UpdateCheckName or app.id
1054 if appid is None or appid == "Ignore":
1056 return appid == package
1059 # Extract some information from the AndroidManifest.xml at the given path.
1060 # Returns (version, vercode, package), any or all of which might be None.
1061 # All values returned are strings.
1062 def parse_androidmanifests(paths, app):
1064 ignoreversions = app.UpdateCheckIgnore
1065 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1068 return (None, None, None)
1076 if not os.path.isfile(path):
1079 logging.debug("Parsing manifest at {0}".format(path))
1080 gradle = has_extension(path, 'gradle')
1086 for line in file(path):
1087 if gradle_comment.match(line):
1089 # Grab first occurence of each to avoid running into
1090 # alternative flavours and builds.
1092 matches = psearch_g(line)
1094 s = matches.group(2)
1095 if app_matches_packagename(app, s):
1098 matches = vnsearch_g(line)
1100 version = matches.group(2)
1102 matches = vcsearch_g(line)
1104 vercode = matches.group(1)
1107 xml = parse_xml(path)
1108 if "package" in xml.attrib:
1109 s = xml.attrib["package"].encode('utf-8')
1110 if app_matches_packagename(app, s):
1112 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1113 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1114 base_dir = os.path.dirname(path)
1115 version = retrieve_string_singleline(base_dir, version)
1116 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1117 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1118 if string_is_integer(a):
1121 logging.warning("Problem with xml at {0}".format(path))
1123 # Remember package name, may be defined separately from version+vercode
1125 package = max_package
1127 logging.debug("..got package={0}, version={1}, vercode={2}"
1128 .format(package, version, vercode))
1130 # Always grab the package name and version name in case they are not
1131 # together with the highest version code
1132 if max_package is None and package is not None:
1133 max_package = package
1134 if max_version is None and version is not None:
1135 max_version = version
1137 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1138 if not ignoresearch or not ignoresearch(version):
1139 if version is not None:
1140 max_version = version
1141 if vercode is not None:
1142 max_vercode = vercode
1143 if package is not None:
1144 max_package = package
1146 max_version = "Ignore"
1148 if max_version is None:
1149 max_version = "Unknown"
1151 if max_package and not is_valid_package_name(max_package):
1152 raise FDroidException("Invalid package name {0}".format(max_package))
1154 return (max_version, max_vercode, max_package)
1157 def is_valid_package_name(name):
1158 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1161 class FDroidException(Exception):
1163 def __init__(self, value, detail=None):
1165 self.detail = detail
1167 def shortened_detail(self):
1168 if len(self.detail) < 16000:
1170 return '[...]\n' + self.detail[-16000:]
1172 def get_wikitext(self):
1173 ret = repr(self.value) + "\n"
1176 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1182 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1186 class VCSException(FDroidException):
1190 class BuildException(FDroidException):
1194 # Get the specified source library.
1195 # Returns the path to it. Normally this is the path to be used when referencing
1196 # it, which may be a subdirectory of the actual project. If you want the base
1197 # directory of the project, pass 'basepath=True'.
1198 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1199 raw=False, prepare=True, preponly=False, refresh=True):
1207 name, ref = spec.split('@')
1209 number, name = name.split(':', 1)
1211 name, subdir = name.split('/', 1)
1213 if name not in metadata.srclibs:
1214 raise VCSException('srclib ' + name + ' not found.')
1216 srclib = metadata.srclibs[name]
1218 sdir = os.path.join(srclib_dir, name)
1221 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1222 vcs.srclib = (name, number, sdir)
1224 vcs.gotorevision(ref, refresh)
1231 libdir = os.path.join(sdir, subdir)
1232 elif srclib["Subdir"]:
1233 for subdir in srclib["Subdir"]:
1234 libdir_candidate = os.path.join(sdir, subdir)
1235 if os.path.exists(libdir_candidate):
1236 libdir = libdir_candidate
1242 remove_signing_keys(sdir)
1243 remove_debuggable_flags(sdir)
1247 if srclib["Prepare"]:
1248 cmd = replace_config_vars(srclib["Prepare"], None)
1250 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1251 if p.returncode != 0:
1252 raise BuildException("Error running prepare command for srclib %s"
1258 return (name, number, libdir)
1260 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1263 # Prepare the source code for a particular build
1264 # 'vcs' - the appropriate vcs object for the application
1265 # 'app' - the application details from the metadata
1266 # 'build' - the build details from the metadata
1267 # 'build_dir' - the path to the build directory, usually
1269 # 'srclib_dir' - the path to the source libraries directory, usually
1271 # 'extlib_dir' - the path to the external libraries directory, usually
1273 # Returns the (root, srclibpaths) where:
1274 # 'root' is the root directory, which may be the same as 'build_dir' or may
1275 # be a subdirectory of it.
1276 # 'srclibpaths' is information on the srclibs being used
1277 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1279 # Optionally, the actual app source can be in a subdirectory
1281 root_dir = os.path.join(build_dir, build.subdir)
1283 root_dir = build_dir
1285 # Get a working copy of the right revision
1286 logging.info("Getting source for revision " + build.commit)
1287 vcs.gotorevision(build.commit, refresh)
1289 # Initialise submodules if required
1290 if build.submodules:
1291 logging.info("Initialising submodules")
1292 vcs.initsubmodules()
1294 # Check that a subdir (if we're using one) exists. This has to happen
1295 # after the checkout, since it might not exist elsewhere
1296 if not os.path.exists(root_dir):
1297 raise BuildException('Missing subdir ' + root_dir)
1299 # Run an init command if one is required
1301 cmd = replace_config_vars(build.init, build)
1302 logging.info("Running 'init' commands in %s" % root_dir)
1304 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1305 if p.returncode != 0:
1306 raise BuildException("Error running init command for %s:%s" %
1307 (app.id, build.version), p.output)
1309 # Apply patches if any
1311 logging.info("Applying patches")
1312 for patch in build.patch:
1313 patch = patch.strip()
1314 logging.info("Applying " + patch)
1315 patch_path = os.path.join('metadata', app.id, patch)
1316 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1317 if p.returncode != 0:
1318 raise BuildException("Failed to apply patch %s" % patch_path)
1320 # Get required source libraries
1323 logging.info("Collecting source libraries")
1324 for lib in build.srclibs:
1325 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1327 for name, number, libpath in srclibpaths:
1328 place_srclib(root_dir, int(number) if number else None, libpath)
1330 basesrclib = vcs.getsrclib()
1331 # If one was used for the main source, add that too.
1333 srclibpaths.append(basesrclib)
1335 # Update the local.properties file
1336 localprops = [os.path.join(build_dir, 'local.properties')]
1338 parts = build.subdir.split(os.sep)
1341 cur = os.path.join(cur, d)
1342 localprops += [os.path.join(cur, 'local.properties')]
1343 for path in localprops:
1345 if os.path.isfile(path):
1346 logging.info("Updating local.properties file at %s" % path)
1347 with open(path, 'r') as f:
1351 logging.info("Creating local.properties file at %s" % path)
1352 # Fix old-fashioned 'sdk-location' by copying
1353 # from sdk.dir, if necessary
1355 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1356 re.S | re.M).group(1)
1357 props += "sdk-location=%s\n" % sdkloc
1359 props += "sdk.dir=%s\n" % config['sdk_path']
1360 props += "sdk-location=%s\n" % config['sdk_path']
1361 ndk_path = build.ndk_path()
1364 props += "ndk.dir=%s\n" % ndk_path
1365 props += "ndk-location=%s\n" % ndk_path
1366 # Add java.encoding if necessary
1368 props += "java.encoding=%s\n" % build.encoding
1369 with open(path, 'w') as f:
1373 if build.method() == 'gradle':
1374 flavours = build.gradle
1377 n = build.target.split('-')[1]
1378 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1379 r'compileSdkVersion %s' % n,
1380 os.path.join(root_dir, 'build.gradle'))
1382 # Remove forced debuggable flags
1383 remove_debuggable_flags(root_dir)
1385 # Insert version code and number into the manifest if necessary
1386 if build.forceversion:
1387 logging.info("Changing the version name")
1388 for path in manifest_paths(root_dir, flavours):
1389 if not os.path.isfile(path):
1391 if has_extension(path, 'xml'):
1392 regsub_file(r'android:versionName="[^"]*"',
1393 r'android:versionName="%s"' % build.version,
1395 elif has_extension(path, 'gradle'):
1396 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1397 r"""\1versionName '%s'""" % build.version,
1400 if build.forcevercode:
1401 logging.info("Changing the version code")
1402 for path in manifest_paths(root_dir, flavours):
1403 if not os.path.isfile(path):
1405 if has_extension(path, 'xml'):
1406 regsub_file(r'android:versionCode="[^"]*"',
1407 r'android:versionCode="%s"' % build.vercode,
1409 elif has_extension(path, 'gradle'):
1410 regsub_file(r'versionCode[ =]+[0-9]+',
1411 r'versionCode %s' % build.vercode,
1414 # Delete unwanted files
1416 logging.info("Removing specified files")
1417 for part in getpaths(build_dir, build.rm):
1418 dest = os.path.join(build_dir, part)
1419 logging.info("Removing {0}".format(part))
1420 if os.path.lexists(dest):
1421 if os.path.islink(dest):
1422 FDroidPopen(['unlink', dest], output=False)
1424 FDroidPopen(['rm', '-rf', dest], output=False)
1426 logging.info("...but it didn't exist")
1428 remove_signing_keys(build_dir)
1430 # Add required external libraries
1432 logging.info("Collecting prebuilt libraries")
1433 libsdir = os.path.join(root_dir, 'libs')
1434 if not os.path.exists(libsdir):
1436 for lib in build.extlibs:
1438 logging.info("...installing extlib {0}".format(lib))
1439 libf = os.path.basename(lib)
1440 libsrc = os.path.join(extlib_dir, lib)
1441 if not os.path.exists(libsrc):
1442 raise BuildException("Missing extlib file {0}".format(libsrc))
1443 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1445 # Run a pre-build command if one is required
1447 logging.info("Running 'prebuild' commands in %s" % root_dir)
1449 cmd = replace_config_vars(build.prebuild, build)
1451 # Substitute source library paths into prebuild commands
1452 for name, number, libpath in srclibpaths:
1453 libpath = os.path.relpath(libpath, root_dir)
1454 cmd = cmd.replace('$$' + name + '$$', libpath)
1456 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1457 if p.returncode != 0:
1458 raise BuildException("Error running prebuild command for %s:%s" %
1459 (app.id, build.version), p.output)
1461 # Generate (or update) the ant build file, build.xml...
1462 if build.method() == 'ant' and build.update != ['no']:
1463 parms = ['android', 'update', 'lib-project']
1464 lparms = ['android', 'update', 'project']
1467 parms += ['-t', build.target]
1468 lparms += ['-t', build.target]
1470 update_dirs = build.update
1472 update_dirs = ant_subprojects(root_dir) + ['.']
1474 for d in update_dirs:
1475 subdir = os.path.join(root_dir, d)
1477 logging.debug("Updating main project")
1478 cmd = parms + ['-p', d]
1480 logging.debug("Updating subproject %s" % d)
1481 cmd = lparms + ['-p', d]
1482 p = SdkToolsPopen(cmd, cwd=root_dir)
1483 # Check to see whether an error was returned without a proper exit
1484 # code (this is the case for the 'no target set or target invalid'
1486 if p.returncode != 0 or p.output.startswith("Error: "):
1487 raise BuildException("Failed to update project at %s" % d, p.output)
1488 # Clean update dirs via ant
1490 logging.info("Cleaning subproject %s" % d)
1491 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1493 return (root_dir, srclibpaths)
1496 # Extend via globbing the paths from a field and return them as a map from
1497 # original path to resulting paths
1498 def getpaths_map(build_dir, globpaths):
1502 full_path = os.path.join(build_dir, p)
1503 full_path = os.path.normpath(full_path)
1504 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1506 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1510 # Extend via globbing the paths from a field and return them as a set
1511 def getpaths(build_dir, globpaths):
1512 paths_map = getpaths_map(build_dir, globpaths)
1514 for k, v in paths_map.iteritems():
1521 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1527 self.path = os.path.join('stats', 'known_apks.txt')
1529 if os.path.isfile(self.path):
1530 for line in file(self.path):
1531 t = line.rstrip().split(' ')
1533 self.apks[t[0]] = (t[1], None)
1535 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1536 self.changed = False
1538 def writeifchanged(self):
1539 if not self.changed:
1542 if not os.path.exists('stats'):
1546 for apk, app in self.apks.iteritems():
1548 line = apk + ' ' + appid
1550 line += ' ' + time.strftime('%Y-%m-%d', added)
1553 with open(self.path, 'w') as f:
1554 for line in sorted(lst, key=natural_key):
1555 f.write(line + '\n')
1557 # Record an apk (if it's new, otherwise does nothing)
1558 # Returns the date it was added.
1559 def recordapk(self, apk, app):
1560 if apk not in self.apks:
1561 self.apks[apk] = (app, time.gmtime(time.time()))
1563 _, added = self.apks[apk]
1566 # Look up information - given the 'apkname', returns (app id, date added/None).
1567 # Or returns None for an unknown apk.
1568 def getapp(self, apkname):
1569 if apkname in self.apks:
1570 return self.apks[apkname]
1573 # Get the most recent 'num' apps added to the repo, as a list of package ids
1574 # with the most recent first.
1575 def getlatest(self, num):
1577 for apk, app in self.apks.iteritems():
1581 if apps[appid] > added:
1585 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1586 lst = [app for app, _ in sortedapps]
1591 def isApkDebuggable(apkfile, config):
1592 """Returns True if the given apk file is debuggable
1594 :param apkfile: full path to the apk to check"""
1596 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1598 if p.returncode != 0:
1599 logging.critical("Failed to get apk manifest information")
1601 for line in p.output.splitlines():
1602 if 'android:debuggable' in line and not line.endswith('0x0'):
1612 def SdkToolsPopen(commands, cwd=None, output=True):
1614 if cmd not in config:
1615 config[cmd] = find_sdk_tools_cmd(commands[0])
1616 abscmd = config[cmd]
1618 logging.critical("Could not find '%s' on your system" % cmd)
1620 return FDroidPopen([abscmd] + commands[1:],
1621 cwd=cwd, output=output)
1624 def FDroidPopen(commands, cwd=None, output=True):
1626 Run a command and capture the possibly huge output.
1628 :param commands: command and argument list like in subprocess.Popen
1629 :param cwd: optionally specifies a working directory
1630 :returns: A PopenResult.
1636 cwd = os.path.normpath(cwd)
1637 logging.debug("Directory: %s" % cwd)
1638 logging.debug("> %s" % ' '.join(commands))
1640 result = PopenResult()
1643 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1644 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1645 except OSError as e:
1646 raise BuildException("OSError while trying to execute " +
1647 ' '.join(commands) + ': ' + str(e))
1649 stdout_queue = Queue()
1650 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1652 # Check the queue for output (until there is no more to get)
1653 while not stdout_reader.eof():
1654 while not stdout_queue.empty():
1655 line = stdout_queue.get()
1656 if output and options.verbose:
1657 # Output directly to console
1658 sys.stderr.write(line)
1660 result.output += line
1664 result.returncode = p.wait()
1668 gradle_comment = re.compile(r'[ ]*//')
1669 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1670 gradle_line_matches = [
1671 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1672 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1673 re.compile(r'.*\.readLine\(.*'),
1677 def remove_signing_keys(build_dir):
1678 for root, dirs, files in os.walk(build_dir):
1679 if 'build.gradle' in files:
1680 path = os.path.join(root, 'build.gradle')
1682 with open(path, "r") as o:
1683 lines = o.readlines()
1689 with open(path, "w") as o:
1690 while i < len(lines):
1693 while line.endswith('\\\n'):
1694 line = line.rstrip('\\\n') + lines[i]
1697 if gradle_comment.match(line):
1702 opened += line.count('{')
1703 opened -= line.count('}')
1706 if gradle_signing_configs.match(line):
1711 if any(s.match(line) for s in gradle_line_matches):
1719 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1722 'project.properties',
1724 'default.properties',
1725 'ant.properties', ]:
1726 if propfile in files:
1727 path = os.path.join(root, propfile)
1729 with open(path, "r") as o:
1730 lines = o.readlines()
1734 with open(path, "w") as o:
1736 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1743 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1746 def reset_env_path():
1747 global env, orig_path
1748 env['PATH'] = orig_path
1751 def add_to_env_path(path):
1753 paths = env['PATH'].split(os.pathsep)
1757 env['PATH'] = os.pathsep.join(paths)
1760 def replace_config_vars(cmd, build):
1762 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1763 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1764 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1765 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1766 if build is not None:
1767 cmd = cmd.replace('$$COMMIT$$', build.commit)
1768 cmd = cmd.replace('$$VERSION$$', build.version)
1769 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1773 def place_srclib(root_dir, number, libpath):
1776 relpath = os.path.relpath(libpath, root_dir)
1777 proppath = os.path.join(root_dir, 'project.properties')
1780 if os.path.isfile(proppath):
1781 with open(proppath, "r") as o:
1782 lines = o.readlines()
1784 with open(proppath, "w") as o:
1787 if line.startswith('android.library.reference.%d=' % number):
1788 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1793 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1795 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1798 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1799 """Verify that two apks are the same
1801 One of the inputs is signed, the other is unsigned. The signature metadata
1802 is transferred from the signed to the unsigned apk, and then jarsigner is
1803 used to verify that the signature from the signed apk is also varlid for
1805 :param signed_apk: Path to a signed apk file
1806 :param unsigned_apk: Path to an unsigned apk file expected to match it
1807 :param tmp_dir: Path to directory for temporary files
1808 :returns: None if the verification is successful, otherwise a string
1809 describing what went wrong.
1811 with ZipFile(signed_apk) as signed_apk_as_zip:
1812 meta_inf_files = ['META-INF/MANIFEST.MF']
1813 for f in signed_apk_as_zip.namelist():
1814 if apk_sigfile.match(f):
1815 meta_inf_files.append(f)
1816 if len(meta_inf_files) < 3:
1817 return "Signature files missing from {0}".format(signed_apk)
1818 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1819 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1820 for meta_inf_file in meta_inf_files:
1821 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1823 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1824 logging.info("...NOT verified - {0}".format(signed_apk))
1825 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1826 logging.info("...successfully verified")
1829 apk_badchars = re.compile('''[/ :;'"]''')
1832 def compare_apks(apk1, apk2, tmp_dir):
1835 Returns None if the apk content is the same (apart from the signing key),
1836 otherwise a string describing what's different, or what went wrong when
1837 trying to do the comparison.
1840 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1841 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1842 for d in [apk1dir, apk2dir]:
1843 if os.path.exists(d):
1846 os.mkdir(os.path.join(d, 'jar-xf'))
1848 if subprocess.call(['jar', 'xf',
1849 os.path.abspath(apk1)],
1850 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1851 return("Failed to unpack " + apk1)
1852 if subprocess.call(['jar', 'xf',
1853 os.path.abspath(apk2)],
1854 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1855 return("Failed to unpack " + apk2)
1857 # try to find apktool in the path, if it hasn't been manually configed
1858 if 'apktool' not in config:
1859 tmp = find_command('apktool')
1861 config['apktool'] = tmp
1862 if 'apktool' in config:
1863 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1865 return("Failed to unpack " + apk1)
1866 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1868 return("Failed to unpack " + apk2)
1870 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1871 lines = p.output.splitlines()
1872 if len(lines) != 1 or 'META-INF' not in lines[0]:
1873 meld = find_command('meld')
1874 if meld is not None:
1875 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1876 return("Unexpected diff output - " + p.output)
1878 # since everything verifies, delete the comparison to keep cruft down
1879 shutil.rmtree(apk1dir)
1880 shutil.rmtree(apk2dir)
1882 # If we get here, it seems like they're the same!
1886 def find_command(command):
1887 '''find the full path of a command, or None if it can't be found in the PATH'''
1890 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1892 fpath, fname = os.path.split(command)
1897 for path in os.environ["PATH"].split(os.pathsep):
1898 path = path.strip('"')
1899 exe_file = os.path.join(path, command)
1900 if is_exe(exe_file):
1907 '''generate a random password for when generating keys'''
1908 h = hashlib.sha256()
1909 h.update(os.urandom(16)) # salt
1910 h.update(bytes(socket.getfqdn()))
1911 return h.digest().encode('base64').strip()
1914 def genkeystore(localconfig):
1915 '''Generate a new key with random passwords and add it to new keystore'''
1916 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1917 keystoredir = os.path.dirname(localconfig['keystore'])
1918 if keystoredir is None or keystoredir == '':
1919 keystoredir = os.path.join(os.getcwd(), keystoredir)
1920 if not os.path.exists(keystoredir):
1921 os.makedirs(keystoredir, mode=0o700)
1923 write_password_file("keystorepass", localconfig['keystorepass'])
1924 write_password_file("keypass", localconfig['keypass'])
1925 p = FDroidPopen([config['keytool'], '-genkey',
1926 '-keystore', localconfig['keystore'],
1927 '-alias', localconfig['repo_keyalias'],
1928 '-keyalg', 'RSA', '-keysize', '4096',
1929 '-sigalg', 'SHA256withRSA',
1930 '-validity', '10000',
1931 '-storepass:file', config['keystorepassfile'],
1932 '-keypass:file', config['keypassfile'],
1933 '-dname', localconfig['keydname']])
1934 # TODO keypass should be sent via stdin
1935 if p.returncode != 0:
1936 raise BuildException("Failed to generate key", p.output)
1937 os.chmod(localconfig['keystore'], 0o0600)
1938 # now show the lovely key that was just generated
1939 p = FDroidPopen([config['keytool'], '-list', '-v',
1940 '-keystore', localconfig['keystore'],
1941 '-alias', localconfig['repo_keyalias'],
1942 '-storepass:file', config['keystorepassfile']])
1943 logging.info(p.output.strip() + '\n\n')
1946 def write_to_config(thisconfig, key, value=None):
1947 '''write a key/value to the local config.py'''
1949 origkey = key + '_orig'
1950 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1951 with open('config.py', 'r') as f:
1953 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1954 repl = '\n' + key + ' = "' + value + '"'
1955 data = re.sub(pattern, repl, data)
1956 # if this key is not in the file, append it
1957 if not re.match('\s*' + key + '\s*=\s*"', data):
1959 # make sure the file ends with a carraige return
1960 if not re.match('\n$', data):
1962 with open('config.py', 'w') as f:
1966 def parse_xml(path):
1967 return XMLElementTree.parse(path).getroot()
1970 def string_is_integer(string):
1978 def get_per_app_repos():
1979 '''per-app repos are dirs named with the packageName of a single app'''
1981 # Android packageNames are Java packages, they may contain uppercase or
1982 # lowercase letters ('A' through 'Z'), numbers, and underscores
1983 # ('_'). However, individual package name parts may only start with
1984 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1985 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1988 for root, dirs, files in os.walk(os.getcwd()):
1990 print('checking', root, 'for', d)
1991 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1992 # standard parts of an fdroid repo, so never packageNames
1995 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):