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 java_version in ('7', '8', '9'):
158 java_home = thisconfig['java_paths'][java_version]
159 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
160 if os.path.exists(jarsigner):
161 thisconfig['jarsigner'] = jarsigner
162 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
163 break # Java7 is preferred, so quit if found
165 for k in ['ndk_paths', 'java_paths']:
171 thisconfig[k][k2] = exp
172 thisconfig[k][k2 + '_orig'] = v
175 def regsub_file(pattern, repl, path):
176 with open(path, 'r') as f:
178 text = re.sub(pattern, repl, text)
179 with open(path, 'w') as f:
183 def read_config(opts, config_file='config.py'):
184 """Read the repository config
186 The config is read from config_file, which is in the current directory when
187 any of the repo management commands are used.
189 global config, options, env, orig_path
191 if config is not None:
193 if not os.path.isfile(config_file):
194 logging.critical("Missing config file - is this a repo directory?")
201 logging.debug("Reading %s" % config_file)
202 execfile(config_file, config)
204 # smartcardoptions must be a list since its command line args for Popen
205 if 'smartcardoptions' in config:
206 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
207 elif 'keystore' in config and config['keystore'] == 'NONE':
208 # keystore='NONE' means use smartcard, these are required defaults
209 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
210 'SunPKCS11-OpenSC', '-providerClass',
211 'sun.security.pkcs11.SunPKCS11',
212 '-providerArg', 'opensc-fdroid.cfg']
214 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
215 st = os.stat(config_file)
216 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
217 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
219 fill_config_defaults(config)
221 # There is no standard, so just set up the most common environment
224 orig_path = env['PATH']
225 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
226 env[n] = config['sdk_path']
228 for k, v in config['java_paths'].items():
229 env['JAVA%s_HOME' % k] = v
231 for k in ["keystorepass", "keypass"]:
233 write_password_file(k)
235 for k in ["repo_description", "archive_description"]:
237 config[k] = clean_description(config[k])
239 if 'serverwebroot' in config:
240 if isinstance(config['serverwebroot'], basestring):
241 roots = [config['serverwebroot']]
242 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
243 roots = config['serverwebroot']
245 raise TypeError('only accepts strings, lists, and tuples')
247 for rootstr in roots:
248 # since this is used with rsync, where trailing slashes have
249 # meaning, ensure there is always a trailing slash
250 if rootstr[-1] != '/':
252 rootlist.append(rootstr.replace('//', '/'))
253 config['serverwebroot'] = rootlist
258 def find_sdk_tools_cmd(cmd):
259 '''find a working path to a tool from the Android SDK'''
262 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
263 # try to find a working path to this command, in all the recent possible paths
264 if 'build_tools' in config:
265 build_tools = os.path.join(config['sdk_path'], 'build-tools')
266 # if 'build_tools' was manually set and exists, check only that one
267 configed_build_tools = os.path.join(build_tools, config['build_tools'])
268 if os.path.exists(configed_build_tools):
269 tooldirs.append(configed_build_tools)
271 # no configed version, so hunt known paths for it
272 for f in sorted(os.listdir(build_tools), reverse=True):
273 if os.path.isdir(os.path.join(build_tools, f)):
274 tooldirs.append(os.path.join(build_tools, f))
275 tooldirs.append(build_tools)
276 sdk_tools = os.path.join(config['sdk_path'], 'tools')
277 if os.path.exists(sdk_tools):
278 tooldirs.append(sdk_tools)
279 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
280 if os.path.exists(sdk_platform_tools):
281 tooldirs.append(sdk_platform_tools)
282 tooldirs.append('/usr/bin')
284 if os.path.isfile(os.path.join(d, cmd)):
285 return os.path.join(d, cmd)
286 # did not find the command, exit with error message
287 ensure_build_tools_exists(config)
290 def test_sdk_exists(thisconfig):
291 if 'sdk_path' not in thisconfig:
292 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
295 logging.error("'sdk_path' not set in config.py!")
297 if thisconfig['sdk_path'] == default_config['sdk_path']:
298 logging.error('No Android SDK found!')
299 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
300 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
302 if not os.path.exists(thisconfig['sdk_path']):
303 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
305 if not os.path.isdir(thisconfig['sdk_path']):
306 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
308 for d in ['build-tools', 'platform-tools', 'tools']:
309 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
310 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
311 thisconfig['sdk_path'], d))
316 def ensure_build_tools_exists(thisconfig):
317 if not test_sdk_exists(thisconfig):
319 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
320 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
321 if not os.path.isdir(versioned_build_tools):
322 logging.critical('Android Build Tools path "'
323 + versioned_build_tools + '" does not exist!')
327 def write_password_file(pwtype, password=None):
329 writes out passwords to a protected file instead of passing passwords as
330 command line argments
332 filename = '.fdroid.' + pwtype + '.txt'
333 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
335 os.write(fd, config[pwtype])
337 os.write(fd, password)
339 config[pwtype + 'file'] = filename
342 # Given the arguments in the form of multiple appid:[vc] strings, this returns
343 # a dictionary with the set of vercodes specified for each package.
344 def read_pkg_args(args, allow_vercodes=False):
351 if allow_vercodes and ':' in p:
352 package, vercode = p.split(':')
354 package, vercode = p, None
355 if package not in vercodes:
356 vercodes[package] = [vercode] if vercode else []
358 elif vercode and vercode not in vercodes[package]:
359 vercodes[package] += [vercode] if vercode else []
364 # On top of what read_pkg_args does, this returns the whole app metadata, but
365 # limiting the builds list to the builds matching the vercodes specified.
366 def read_app_args(args, allapps, allow_vercodes=False):
368 vercodes = read_pkg_args(args, allow_vercodes)
374 for appid, app in allapps.iteritems():
375 if appid in vercodes:
378 if len(apps) != len(vercodes):
381 logging.critical("No such package: %s" % p)
382 raise FDroidException("Found invalid app ids in arguments")
384 raise FDroidException("No packages specified")
387 for appid, app in apps.iteritems():
391 app.builds = [b for b in app.builds if b.vercode in vc]
392 if len(app.builds) != len(vercodes[appid]):
394 allvcs = [b.vercode for b in app.builds]
395 for v in vercodes[appid]:
397 logging.critical("No such vercode %s for app %s" % (v, appid))
400 raise FDroidException("Found invalid vercodes for some apps")
405 def get_extension(filename):
406 base, ext = os.path.splitext(filename)
409 return base, ext.lower()[1:]
412 def has_extension(filename, ext):
413 _, f_ext = get_extension(filename)
417 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
420 def clean_description(description):
421 'Remove unneeded newlines and spaces from a block of description text'
423 # this is split up by paragraph to make removing the newlines easier
424 for paragraph in re.split(r'\n\n', description):
425 paragraph = re.sub('\r', '', paragraph)
426 paragraph = re.sub('\n', ' ', paragraph)
427 paragraph = re.sub(' {2,}', ' ', paragraph)
428 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
429 returnstring += paragraph + '\n\n'
430 return returnstring.rstrip('\n')
433 def apknameinfo(filename):
434 filename = os.path.basename(filename)
435 m = apk_regex.match(filename)
437 result = (m.group(1), m.group(2))
438 except AttributeError:
439 raise FDroidException("Invalid apk name: %s" % filename)
443 def getapkname(app, build):
444 return "%s_%s.apk" % (app.id, build.vercode)
447 def getsrcname(app, build):
448 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
460 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
463 def getvcs(vcstype, remote, local):
465 return vcs_git(remote, local)
466 if vcstype == 'git-svn':
467 return vcs_gitsvn(remote, local)
469 return vcs_hg(remote, local)
471 return vcs_bzr(remote, local)
472 if vcstype == 'srclib':
473 if local != os.path.join('build', 'srclib', remote):
474 raise VCSException("Error: srclib paths are hard-coded!")
475 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
477 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
478 raise VCSException("Invalid vcs type " + vcstype)
481 def getsrclibvcs(name):
482 if name not in metadata.srclibs:
483 raise VCSException("Missing srclib " + name)
484 return metadata.srclibs[name]['Repo Type']
489 def __init__(self, remote, local):
491 # svn, git-svn and bzr may require auth
493 if self.repotype() in ('git-svn', 'bzr'):
495 if self.repotype == 'git-svn':
496 raise VCSException("Authentication is not supported for git-svn")
497 self.username, remote = remote.split('@')
498 if ':' not in self.username:
499 raise VCSException("Password required with username")
500 self.username, self.password = self.username.split(':')
504 self.clone_failed = False
505 self.refreshed = False
511 # Take the local repository to a clean version of the given revision, which
512 # is specificed in the VCS's native format. Beforehand, the repository can
513 # be dirty, or even non-existent. If the repository does already exist
514 # locally, it will be updated from the origin, but only once in the
515 # lifetime of the vcs object.
516 # None is acceptable for 'rev' if you know you are cloning a clean copy of
517 # the repo - otherwise it must specify a valid revision.
518 def gotorevision(self, rev, refresh=True):
520 if self.clone_failed:
521 raise VCSException("Downloading the repository already failed once, not trying again.")
523 # The .fdroidvcs-id file for a repo tells us what VCS type
524 # and remote that directory was created from, allowing us to drop it
525 # automatically if either of those things changes.
526 fdpath = os.path.join(self.local, '..',
527 '.fdroidvcs-' + os.path.basename(self.local))
528 cdata = self.repotype() + ' ' + self.remote
531 if os.path.exists(self.local):
532 if os.path.exists(fdpath):
533 with open(fdpath, 'r') as f:
534 fsdata = f.read().strip()
539 logging.info("Repository details for %s changed - deleting" % (
543 logging.info("Repository details for %s missing - deleting" % (
546 shutil.rmtree(self.local)
550 self.refreshed = True
553 self.gotorevisionx(rev)
554 except FDroidException as e:
557 # If necessary, write the .fdroidvcs file.
558 if writeback and not self.clone_failed:
559 with open(fdpath, 'w') as f:
565 # Derived classes need to implement this. It's called once basic checking
566 # has been performend.
567 def gotorevisionx(self, rev):
568 raise VCSException("This VCS type doesn't define gotorevisionx")
570 # Initialise and update submodules
571 def initsubmodules(self):
572 raise VCSException('Submodules not supported for this vcs type')
574 # Get a list of all known tags
576 if not self._gettags:
577 raise VCSException('gettags not supported for this vcs type')
579 for tag in self._gettags():
580 if re.match('[-A-Za-z0-9_. /]+$', tag):
584 def latesttags(self, tags, number):
585 """Get the most recent tags in a given list.
587 :param tags: a list of tags
588 :param number: the number to return
589 :returns: A list containing the most recent tags in the provided
590 list, up to the maximum number given.
592 raise VCSException('latesttags not supported for this vcs type')
594 # Get current commit reference (hash, revision, etc)
596 raise VCSException('getref not supported for this vcs type')
598 # Returns the srclib (name, path) used in setting up the current
609 # If the local directory exists, but is somehow not a git repository, git
610 # will traverse up the directory tree until it finds one that is (i.e.
611 # fdroidserver) and then we'll proceed to destroy it! This is called as
614 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
615 result = p.output.rstrip()
616 if not result.endswith(self.local):
617 raise VCSException('Repository mismatch')
619 def gotorevisionx(self, rev):
620 if not os.path.exists(self.local):
622 p = FDroidPopen(['git', 'clone', self.remote, self.local])
623 if p.returncode != 0:
624 self.clone_failed = True
625 raise VCSException("Git clone failed", p.output)
629 # Discard any working tree changes
630 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
631 'git', 'reset', '--hard'], cwd=self.local, output=False)
632 if p.returncode != 0:
633 raise VCSException("Git reset failed", p.output)
634 # Remove untracked files now, in case they're tracked in the target
635 # revision (it happens!)
636 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
637 'git', 'clean', '-dffx'], cwd=self.local, output=False)
638 if p.returncode != 0:
639 raise VCSException("Git clean failed", p.output)
640 if not self.refreshed:
641 # Get latest commits and tags from remote
642 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
643 if p.returncode != 0:
644 raise VCSException("Git fetch failed", p.output)
645 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
646 if p.returncode != 0:
647 raise VCSException("Git fetch failed", p.output)
648 # Recreate origin/HEAD as git clone would do it, in case it disappeared
649 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
650 if p.returncode != 0:
651 lines = p.output.splitlines()
652 if 'Multiple remote HEAD branches' not in lines[0]:
653 raise VCSException("Git remote set-head failed", p.output)
654 branch = lines[1].split(' ')[-1]
655 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
656 if p2.returncode != 0:
657 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
658 self.refreshed = True
659 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
660 # a github repo. Most of the time this is the same as origin/master.
661 rev = rev or 'origin/HEAD'
662 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
663 if p.returncode != 0:
664 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
665 # Get rid of any uncontrolled files left behind
666 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
667 if p.returncode != 0:
668 raise VCSException("Git clean failed", p.output)
670 def initsubmodules(self):
672 submfile = os.path.join(self.local, '.gitmodules')
673 if not os.path.isfile(submfile):
674 raise VCSException("No git submodules available")
676 # fix submodules not accessible without an account and public key auth
677 with open(submfile, 'r') as f:
678 lines = f.readlines()
679 with open(submfile, 'w') as f:
681 if 'git@github.com' in line:
682 line = line.replace('git@github.com:', 'https://github.com/')
683 if 'git@gitlab.com' in line:
684 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
687 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
688 if p.returncode != 0:
689 raise VCSException("Git submodule sync failed", p.output)
690 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
691 if p.returncode != 0:
692 raise VCSException("Git submodule update failed", p.output)
696 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
697 return p.output.splitlines()
699 def latesttags(self, tags, number):
704 ['git', 'show', '--format=format:%ct', '-s', tag],
705 cwd=self.local, output=False)
706 # Timestamp is on the last line. For a normal tag, it's the only
707 # line, but for annotated tags, the rest of the info precedes it.
708 ts = int(p.output.splitlines()[-1])
711 for _, t in sorted(tl)[-number:]:
716 class vcs_gitsvn(vcs):
721 # If the local directory exists, but is somehow not a git repository, git
722 # will traverse up the directory tree until it finds one that is (i.e.
723 # fdroidserver) and then we'll proceed to destory it! This is called as
726 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
727 result = p.output.rstrip()
728 if not result.endswith(self.local):
729 raise VCSException('Repository mismatch')
731 def gotorevisionx(self, rev):
732 if not os.path.exists(self.local):
734 gitsvn_args = ['git', 'svn', 'clone']
735 if ';' in self.remote:
736 remote_split = self.remote.split(';')
737 for i in remote_split[1:]:
738 if i.startswith('trunk='):
739 gitsvn_args.extend(['-T', i[6:]])
740 elif i.startswith('tags='):
741 gitsvn_args.extend(['-t', i[5:]])
742 elif i.startswith('branches='):
743 gitsvn_args.extend(['-b', i[9:]])
744 gitsvn_args.extend([remote_split[0], self.local])
745 p = FDroidPopen(gitsvn_args, output=False)
746 if p.returncode != 0:
747 self.clone_failed = True
748 raise VCSException("Git svn clone failed", p.output)
750 gitsvn_args.extend([self.remote, self.local])
751 p = FDroidPopen(gitsvn_args, output=False)
752 if p.returncode != 0:
753 self.clone_failed = True
754 raise VCSException("Git svn clone failed", p.output)
758 # Discard any working tree changes
759 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
760 if p.returncode != 0:
761 raise VCSException("Git reset failed", p.output)
762 # Remove untracked files now, in case they're tracked in the target
763 # revision (it happens!)
764 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
765 if p.returncode != 0:
766 raise VCSException("Git clean failed", p.output)
767 if not self.refreshed:
768 # Get new commits, branches and tags from repo
769 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
770 if p.returncode != 0:
771 raise VCSException("Git svn fetch failed")
772 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
773 if p.returncode != 0:
774 raise VCSException("Git svn rebase failed", p.output)
775 self.refreshed = True
777 rev = rev or 'master'
779 nospaces_rev = rev.replace(' ', '%20')
780 # Try finding a svn tag
781 for treeish in ['origin/', '']:
782 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
783 if p.returncode == 0:
785 if p.returncode != 0:
786 # No tag found, normal svn rev translation
787 # Translate svn rev into git format
788 rev_split = rev.split('/')
791 for treeish in ['origin/', '']:
792 if len(rev_split) > 1:
793 treeish += rev_split[0]
794 svn_rev = rev_split[1]
797 # if no branch is specified, then assume trunk (i.e. 'master' branch):
801 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
803 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
804 git_rev = p.output.rstrip()
806 if p.returncode == 0 and git_rev:
809 if p.returncode != 0 or not git_rev:
810 # Try a plain git checkout as a last resort
811 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
812 if p.returncode != 0:
813 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
815 # Check out the git rev equivalent to the svn rev
816 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
817 if p.returncode != 0:
818 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
820 # Get rid of any uncontrolled files left behind
821 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
822 if p.returncode != 0:
823 raise VCSException("Git clean failed", p.output)
827 for treeish in ['origin/', '']:
828 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
834 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
835 if p.returncode != 0:
837 return p.output.strip()
845 def gotorevisionx(self, rev):
846 if not os.path.exists(self.local):
847 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
848 if p.returncode != 0:
849 self.clone_failed = True
850 raise VCSException("Hg clone failed", p.output)
852 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
853 if p.returncode != 0:
854 raise VCSException("Hg status failed", p.output)
855 for line in p.output.splitlines():
856 if not line.startswith('? '):
857 raise VCSException("Unexpected output from hg status -uS: " + line)
858 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
859 if not self.refreshed:
860 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
861 if p.returncode != 0:
862 raise VCSException("Hg pull failed", p.output)
863 self.refreshed = True
865 rev = rev or 'default'
868 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
871 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
872 # Also delete untracked files, we have to enable purge extension for that:
873 if "'purge' is provided by the following extension" in p.output:
874 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
875 myfile.write("\n[extensions]\nhgext.purge=\n")
876 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
877 if p.returncode != 0:
878 raise VCSException("HG purge failed", p.output)
879 elif p.returncode != 0:
880 raise VCSException("HG purge failed", p.output)
883 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
884 return p.output.splitlines()[1:]
892 def gotorevisionx(self, rev):
893 if not os.path.exists(self.local):
894 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
895 if p.returncode != 0:
896 self.clone_failed = True
897 raise VCSException("Bzr branch failed", p.output)
899 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
900 if p.returncode != 0:
901 raise VCSException("Bzr revert failed", p.output)
902 if not self.refreshed:
903 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
904 if p.returncode != 0:
905 raise VCSException("Bzr update failed", p.output)
906 self.refreshed = True
908 revargs = list(['-r', rev] if rev else [])
909 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
910 if p.returncode != 0:
911 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
914 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
915 return [tag.split(' ')[0].strip() for tag in
916 p.output.splitlines()]
919 def unescape_string(string):
922 if string[0] == '"' and string[-1] == '"':
925 return string.replace("\\'", "'")
928 def retrieve_string(app_dir, string, xmlfiles=None):
930 if not string.startswith('@string/'):
931 return unescape_string(string)
936 os.path.join(app_dir, 'res'),
937 os.path.join(app_dir, 'src', 'main', 'res'),
939 for r, d, f in os.walk(res_dir):
940 if os.path.basename(r) == 'values':
941 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
943 name = string[len('@string/'):]
945 def element_content(element):
946 if element.text is None:
948 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
951 for path in xmlfiles:
952 if not os.path.isfile(path):
954 xml = parse_xml(path)
955 element = xml.find('string[@name="' + name + '"]')
956 if element is not None:
957 content = element_content(element)
958 return retrieve_string(app_dir, content, xmlfiles)
963 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
964 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
967 # Return list of existing files that will be used to find the highest vercode
968 def manifest_paths(app_dir, flavours):
970 possible_manifests = \
971 [os.path.join(app_dir, 'AndroidManifest.xml'),
972 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
973 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
974 os.path.join(app_dir, 'build.gradle')]
976 for flavour in flavours:
979 possible_manifests.append(
980 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
982 return [path for path in possible_manifests if os.path.isfile(path)]
985 # Retrieve the package name. Returns the name, or None if not found.
986 def fetch_real_name(app_dir, flavours):
987 for path in manifest_paths(app_dir, flavours):
988 if not has_extension(path, 'xml') or not os.path.isfile(path):
990 logging.debug("fetch_real_name: Checking manifest at " + path)
991 xml = parse_xml(path)
992 app = xml.find('application')
995 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
997 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
998 result = retrieve_string_singleline(app_dir, label)
1000 result = result.strip()
1005 def get_library_references(root_dir):
1007 proppath = os.path.join(root_dir, 'project.properties')
1008 if not os.path.isfile(proppath):
1010 for line in file(proppath):
1011 if not line.startswith('android.library.reference.'):
1013 path = line.split('=')[1].strip()
1014 relpath = os.path.join(root_dir, path)
1015 if not os.path.isdir(relpath):
1017 logging.debug("Found subproject at %s" % path)
1018 libraries.append(path)
1022 def ant_subprojects(root_dir):
1023 subprojects = get_library_references(root_dir)
1024 for subpath in subprojects:
1025 subrelpath = os.path.join(root_dir, subpath)
1026 for p in get_library_references(subrelpath):
1027 relp = os.path.normpath(os.path.join(subpath, p))
1028 if relp not in subprojects:
1029 subprojects.insert(0, relp)
1033 def remove_debuggable_flags(root_dir):
1034 # Remove forced debuggable flags
1035 logging.debug("Removing debuggable flags from %s" % root_dir)
1036 for root, dirs, files in os.walk(root_dir):
1037 if 'AndroidManifest.xml' in files:
1038 regsub_file(r'android:debuggable="[^"]*"',
1040 os.path.join(root, 'AndroidManifest.xml'))
1043 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1044 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1045 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1048 def app_matches_packagename(app, package):
1051 appid = app.UpdateCheckName or app.id
1052 if appid is None or appid == "Ignore":
1054 return appid == package
1057 # Extract some information from the AndroidManifest.xml at the given path.
1058 # Returns (version, vercode, package), any or all of which might be None.
1059 # All values returned are strings.
1060 def parse_androidmanifests(paths, app):
1062 ignoreversions = app.UpdateCheckIgnore
1063 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1066 return (None, None, None)
1074 if not os.path.isfile(path):
1077 logging.debug("Parsing manifest at {0}".format(path))
1078 gradle = has_extension(path, 'gradle')
1084 for line in file(path):
1085 if gradle_comment.match(line):
1087 # Grab first occurence of each to avoid running into
1088 # alternative flavours and builds.
1090 matches = psearch_g(line)
1092 s = matches.group(2)
1093 if app_matches_packagename(app, s):
1096 matches = vnsearch_g(line)
1098 version = matches.group(2)
1100 matches = vcsearch_g(line)
1102 vercode = matches.group(1)
1105 xml = parse_xml(path)
1106 if "package" in xml.attrib:
1107 s = xml.attrib["package"].encode('utf-8')
1108 if app_matches_packagename(app, s):
1110 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1111 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1112 base_dir = os.path.dirname(path)
1113 version = retrieve_string_singleline(base_dir, version)
1114 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1115 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1116 if string_is_integer(a):
1119 logging.warning("Problem with xml at {0}".format(path))
1121 # Remember package name, may be defined separately from version+vercode
1123 package = max_package
1125 logging.debug("..got package={0}, version={1}, vercode={2}"
1126 .format(package, version, vercode))
1128 # Always grab the package name and version name in case they are not
1129 # together with the highest version code
1130 if max_package is None and package is not None:
1131 max_package = package
1132 if max_version is None and version is not None:
1133 max_version = version
1135 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1136 if not ignoresearch or not ignoresearch(version):
1137 if version is not None:
1138 max_version = version
1139 if vercode is not None:
1140 max_vercode = vercode
1141 if package is not None:
1142 max_package = package
1144 max_version = "Ignore"
1146 if max_version is None:
1147 max_version = "Unknown"
1149 if max_package and not is_valid_package_name(max_package):
1150 raise FDroidException("Invalid package name {0}".format(max_package))
1152 return (max_version, max_vercode, max_package)
1155 def is_valid_package_name(name):
1156 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1159 class FDroidException(Exception):
1161 def __init__(self, value, detail=None):
1163 self.detail = detail
1165 def shortened_detail(self):
1166 if len(self.detail) < 16000:
1168 return '[...]\n' + self.detail[-16000:]
1170 def get_wikitext(self):
1171 ret = repr(self.value) + "\n"
1174 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1180 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1184 class VCSException(FDroidException):
1188 class BuildException(FDroidException):
1192 # Get the specified source library.
1193 # Returns the path to it. Normally this is the path to be used when referencing
1194 # it, which may be a subdirectory of the actual project. If you want the base
1195 # directory of the project, pass 'basepath=True'.
1196 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1197 raw=False, prepare=True, preponly=False, refresh=True):
1205 name, ref = spec.split('@')
1207 number, name = name.split(':', 1)
1209 name, subdir = name.split('/', 1)
1211 if name not in metadata.srclibs:
1212 raise VCSException('srclib ' + name + ' not found.')
1214 srclib = metadata.srclibs[name]
1216 sdir = os.path.join(srclib_dir, name)
1219 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1220 vcs.srclib = (name, number, sdir)
1222 vcs.gotorevision(ref, refresh)
1229 libdir = os.path.join(sdir, subdir)
1230 elif srclib["Subdir"]:
1231 for subdir in srclib["Subdir"]:
1232 libdir_candidate = os.path.join(sdir, subdir)
1233 if os.path.exists(libdir_candidate):
1234 libdir = libdir_candidate
1240 remove_signing_keys(sdir)
1241 remove_debuggable_flags(sdir)
1245 if srclib["Prepare"]:
1246 cmd = replace_config_vars(srclib["Prepare"], None)
1248 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1249 if p.returncode != 0:
1250 raise BuildException("Error running prepare command for srclib %s"
1256 return (name, number, libdir)
1258 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1261 # Prepare the source code for a particular build
1262 # 'vcs' - the appropriate vcs object for the application
1263 # 'app' - the application details from the metadata
1264 # 'build' - the build details from the metadata
1265 # 'build_dir' - the path to the build directory, usually
1267 # 'srclib_dir' - the path to the source libraries directory, usually
1269 # 'extlib_dir' - the path to the external libraries directory, usually
1271 # Returns the (root, srclibpaths) where:
1272 # 'root' is the root directory, which may be the same as 'build_dir' or may
1273 # be a subdirectory of it.
1274 # 'srclibpaths' is information on the srclibs being used
1275 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1277 # Optionally, the actual app source can be in a subdirectory
1279 root_dir = os.path.join(build_dir, build.subdir)
1281 root_dir = build_dir
1283 # Get a working copy of the right revision
1284 logging.info("Getting source for revision " + build.commit)
1285 vcs.gotorevision(build.commit, refresh)
1287 # Initialise submodules if required
1288 if build.submodules:
1289 logging.info("Initialising submodules")
1290 vcs.initsubmodules()
1292 # Check that a subdir (if we're using one) exists. This has to happen
1293 # after the checkout, since it might not exist elsewhere
1294 if not os.path.exists(root_dir):
1295 raise BuildException('Missing subdir ' + root_dir)
1297 # Run an init command if one is required
1299 cmd = replace_config_vars(build.init, build)
1300 logging.info("Running 'init' commands in %s" % root_dir)
1302 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1303 if p.returncode != 0:
1304 raise BuildException("Error running init command for %s:%s" %
1305 (app.id, build.version), p.output)
1307 # Apply patches if any
1309 logging.info("Applying patches")
1310 for patch in build.patch:
1311 patch = patch.strip()
1312 logging.info("Applying " + patch)
1313 patch_path = os.path.join('metadata', app.id, patch)
1314 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1315 if p.returncode != 0:
1316 raise BuildException("Failed to apply patch %s" % patch_path)
1318 # Get required source libraries
1321 logging.info("Collecting source libraries")
1322 for lib in build.srclibs:
1323 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1325 for name, number, libpath in srclibpaths:
1326 place_srclib(root_dir, int(number) if number else None, libpath)
1328 basesrclib = vcs.getsrclib()
1329 # If one was used for the main source, add that too.
1331 srclibpaths.append(basesrclib)
1333 # Update the local.properties file
1334 localprops = [os.path.join(build_dir, 'local.properties')]
1336 parts = build.subdir.split(os.sep)
1339 cur = os.path.join(cur, d)
1340 localprops += [os.path.join(cur, 'local.properties')]
1341 for path in localprops:
1343 if os.path.isfile(path):
1344 logging.info("Updating local.properties file at %s" % path)
1345 with open(path, 'r') as f:
1349 logging.info("Creating local.properties file at %s" % path)
1350 # Fix old-fashioned 'sdk-location' by copying
1351 # from sdk.dir, if necessary
1353 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1354 re.S | re.M).group(1)
1355 props += "sdk-location=%s\n" % sdkloc
1357 props += "sdk.dir=%s\n" % config['sdk_path']
1358 props += "sdk-location=%s\n" % config['sdk_path']
1359 ndk_path = build.ndk_path()
1362 props += "ndk.dir=%s\n" % ndk_path
1363 props += "ndk-location=%s\n" % ndk_path
1364 # Add java.encoding if necessary
1366 props += "java.encoding=%s\n" % build.encoding
1367 with open(path, 'w') as f:
1371 if build.method() == 'gradle':
1372 flavours = build.gradle
1375 n = build.target.split('-')[1]
1376 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1377 r'compileSdkVersion %s' % n,
1378 os.path.join(root_dir, 'build.gradle'))
1380 # Remove forced debuggable flags
1381 remove_debuggable_flags(root_dir)
1383 # Insert version code and number into the manifest if necessary
1384 if build.forceversion:
1385 logging.info("Changing the version name")
1386 for path in manifest_paths(root_dir, flavours):
1387 if not os.path.isfile(path):
1389 if has_extension(path, 'xml'):
1390 regsub_file(r'android:versionName="[^"]*"',
1391 r'android:versionName="%s"' % build.version,
1393 elif has_extension(path, 'gradle'):
1394 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1395 r"""\1versionName '%s'""" % build.version,
1398 if build.forcevercode:
1399 logging.info("Changing the version code")
1400 for path in manifest_paths(root_dir, flavours):
1401 if not os.path.isfile(path):
1403 if has_extension(path, 'xml'):
1404 regsub_file(r'android:versionCode="[^"]*"',
1405 r'android:versionCode="%s"' % build.vercode,
1407 elif has_extension(path, 'gradle'):
1408 regsub_file(r'versionCode[ =]+[0-9]+',
1409 r'versionCode %s' % build.vercode,
1412 # Delete unwanted files
1414 logging.info("Removing specified files")
1415 for part in getpaths(build_dir, build.rm):
1416 dest = os.path.join(build_dir, part)
1417 logging.info("Removing {0}".format(part))
1418 if os.path.lexists(dest):
1419 if os.path.islink(dest):
1420 FDroidPopen(['unlink', dest], output=False)
1422 FDroidPopen(['rm', '-rf', dest], output=False)
1424 logging.info("...but it didn't exist")
1426 remove_signing_keys(build_dir)
1428 # Add required external libraries
1430 logging.info("Collecting prebuilt libraries")
1431 libsdir = os.path.join(root_dir, 'libs')
1432 if not os.path.exists(libsdir):
1434 for lib in build.extlibs:
1436 logging.info("...installing extlib {0}".format(lib))
1437 libf = os.path.basename(lib)
1438 libsrc = os.path.join(extlib_dir, lib)
1439 if not os.path.exists(libsrc):
1440 raise BuildException("Missing extlib file {0}".format(libsrc))
1441 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1443 # Run a pre-build command if one is required
1445 logging.info("Running 'prebuild' commands in %s" % root_dir)
1447 cmd = replace_config_vars(build.prebuild, build)
1449 # Substitute source library paths into prebuild commands
1450 for name, number, libpath in srclibpaths:
1451 libpath = os.path.relpath(libpath, root_dir)
1452 cmd = cmd.replace('$$' + name + '$$', libpath)
1454 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1455 if p.returncode != 0:
1456 raise BuildException("Error running prebuild command for %s:%s" %
1457 (app.id, build.version), p.output)
1459 # Generate (or update) the ant build file, build.xml...
1460 if build.method() == 'ant' and build.update != ['no']:
1461 parms = ['android', 'update', 'lib-project']
1462 lparms = ['android', 'update', 'project']
1465 parms += ['-t', build.target]
1466 lparms += ['-t', build.target]
1468 update_dirs = build.update
1470 update_dirs = ant_subprojects(root_dir) + ['.']
1472 for d in update_dirs:
1473 subdir = os.path.join(root_dir, d)
1475 logging.debug("Updating main project")
1476 cmd = parms + ['-p', d]
1478 logging.debug("Updating subproject %s" % d)
1479 cmd = lparms + ['-p', d]
1480 p = SdkToolsPopen(cmd, cwd=root_dir)
1481 # Check to see whether an error was returned without a proper exit
1482 # code (this is the case for the 'no target set or target invalid'
1484 if p.returncode != 0 or p.output.startswith("Error: "):
1485 raise BuildException("Failed to update project at %s" % d, p.output)
1486 # Clean update dirs via ant
1488 logging.info("Cleaning subproject %s" % d)
1489 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1491 return (root_dir, srclibpaths)
1494 # Extend via globbing the paths from a field and return them as a map from
1495 # original path to resulting paths
1496 def getpaths_map(build_dir, globpaths):
1500 full_path = os.path.join(build_dir, p)
1501 full_path = os.path.normpath(full_path)
1502 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1504 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1508 # Extend via globbing the paths from a field and return them as a set
1509 def getpaths(build_dir, globpaths):
1510 paths_map = getpaths_map(build_dir, globpaths)
1512 for k, v in paths_map.iteritems():
1519 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1525 self.path = os.path.join('stats', 'known_apks.txt')
1527 if os.path.isfile(self.path):
1528 for line in file(self.path):
1529 t = line.rstrip().split(' ')
1531 self.apks[t[0]] = (t[1], None)
1533 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1534 self.changed = False
1536 def writeifchanged(self):
1537 if not self.changed:
1540 if not os.path.exists('stats'):
1544 for apk, app in self.apks.iteritems():
1546 line = apk + ' ' + appid
1548 line += ' ' + time.strftime('%Y-%m-%d', added)
1551 with open(self.path, 'w') as f:
1552 for line in sorted(lst, key=natural_key):
1553 f.write(line + '\n')
1555 # Record an apk (if it's new, otherwise does nothing)
1556 # Returns the date it was added.
1557 def recordapk(self, apk, app):
1558 if apk not in self.apks:
1559 self.apks[apk] = (app, time.gmtime(time.time()))
1561 _, added = self.apks[apk]
1564 # Look up information - given the 'apkname', returns (app id, date added/None).
1565 # Or returns None for an unknown apk.
1566 def getapp(self, apkname):
1567 if apkname in self.apks:
1568 return self.apks[apkname]
1571 # Get the most recent 'num' apps added to the repo, as a list of package ids
1572 # with the most recent first.
1573 def getlatest(self, num):
1575 for apk, app in self.apks.iteritems():
1579 if apps[appid] > added:
1583 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1584 lst = [app for app, _ in sortedapps]
1589 def isApkDebuggable(apkfile, config):
1590 """Returns True if the given apk file is debuggable
1592 :param apkfile: full path to the apk to check"""
1594 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1596 if p.returncode != 0:
1597 logging.critical("Failed to get apk manifest information")
1599 for line in p.output.splitlines():
1600 if 'android:debuggable' in line and not line.endswith('0x0'):
1610 def SdkToolsPopen(commands, cwd=None, output=True):
1612 if cmd not in config:
1613 config[cmd] = find_sdk_tools_cmd(commands[0])
1614 abscmd = config[cmd]
1616 logging.critical("Could not find '%s' on your system" % cmd)
1618 return FDroidPopen([abscmd] + commands[1:],
1619 cwd=cwd, output=output)
1622 def FDroidPopen(commands, cwd=None, output=True):
1624 Run a command and capture the possibly huge output.
1626 :param commands: command and argument list like in subprocess.Popen
1627 :param cwd: optionally specifies a working directory
1628 :returns: A PopenResult.
1634 cwd = os.path.normpath(cwd)
1635 logging.debug("Directory: %s" % cwd)
1636 logging.debug("> %s" % ' '.join(commands))
1638 result = PopenResult()
1641 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1642 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1643 except OSError as e:
1644 raise BuildException("OSError while trying to execute " +
1645 ' '.join(commands) + ': ' + str(e))
1647 stdout_queue = Queue()
1648 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1650 # Check the queue for output (until there is no more to get)
1651 while not stdout_reader.eof():
1652 while not stdout_queue.empty():
1653 line = stdout_queue.get()
1654 if output and options.verbose:
1655 # Output directly to console
1656 sys.stderr.write(line)
1658 result.output += line
1662 result.returncode = p.wait()
1666 gradle_comment = re.compile(r'[ ]*//')
1667 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1668 gradle_line_matches = [
1669 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1670 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1671 re.compile(r'.*\.readLine\(.*'),
1675 def remove_signing_keys(build_dir):
1676 for root, dirs, files in os.walk(build_dir):
1677 if 'build.gradle' in files:
1678 path = os.path.join(root, 'build.gradle')
1680 with open(path, "r") as o:
1681 lines = o.readlines()
1687 with open(path, "w") as o:
1688 while i < len(lines):
1691 while line.endswith('\\\n'):
1692 line = line.rstrip('\\\n') + lines[i]
1695 if gradle_comment.match(line):
1700 opened += line.count('{')
1701 opened -= line.count('}')
1704 if gradle_signing_configs.match(line):
1709 if any(s.match(line) for s in gradle_line_matches):
1717 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1720 'project.properties',
1722 'default.properties',
1723 'ant.properties', ]:
1724 if propfile in files:
1725 path = os.path.join(root, propfile)
1727 with open(path, "r") as o:
1728 lines = o.readlines()
1732 with open(path, "w") as o:
1734 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1741 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1744 def reset_env_path():
1745 global env, orig_path
1746 env['PATH'] = orig_path
1749 def add_to_env_path(path):
1751 paths = env['PATH'].split(os.pathsep)
1755 env['PATH'] = os.pathsep.join(paths)
1758 def replace_config_vars(cmd, build):
1760 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1761 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1762 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1763 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1764 if build is not None:
1765 cmd = cmd.replace('$$COMMIT$$', build.commit)
1766 cmd = cmd.replace('$$VERSION$$', build.version)
1767 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1771 def place_srclib(root_dir, number, libpath):
1774 relpath = os.path.relpath(libpath, root_dir)
1775 proppath = os.path.join(root_dir, 'project.properties')
1778 if os.path.isfile(proppath):
1779 with open(proppath, "r") as o:
1780 lines = o.readlines()
1782 with open(proppath, "w") as o:
1785 if line.startswith('android.library.reference.%d=' % number):
1786 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1791 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1793 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1796 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1797 """Verify that two apks are the same
1799 One of the inputs is signed, the other is unsigned. The signature metadata
1800 is transferred from the signed to the unsigned apk, and then jarsigner is
1801 used to verify that the signature from the signed apk is also varlid for
1803 :param signed_apk: Path to a signed apk file
1804 :param unsigned_apk: Path to an unsigned apk file expected to match it
1805 :param tmp_dir: Path to directory for temporary files
1806 :returns: None if the verification is successful, otherwise a string
1807 describing what went wrong.
1809 with ZipFile(signed_apk) as signed_apk_as_zip:
1810 meta_inf_files = ['META-INF/MANIFEST.MF']
1811 for f in signed_apk_as_zip.namelist():
1812 if apk_sigfile.match(f):
1813 meta_inf_files.append(f)
1814 if len(meta_inf_files) < 3:
1815 return "Signature files missing from {0}".format(signed_apk)
1816 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1817 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1818 for meta_inf_file in meta_inf_files:
1819 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1821 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1822 logging.info("...NOT verified - {0}".format(signed_apk))
1823 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1824 logging.info("...successfully verified")
1827 apk_badchars = re.compile('''[/ :;'"]''')
1830 def compare_apks(apk1, apk2, tmp_dir):
1833 Returns None if the apk content is the same (apart from the signing key),
1834 otherwise a string describing what's different, or what went wrong when
1835 trying to do the comparison.
1838 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1839 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1840 for d in [apk1dir, apk2dir]:
1841 if os.path.exists(d):
1844 os.mkdir(os.path.join(d, 'jar-xf'))
1846 if subprocess.call(['jar', 'xf',
1847 os.path.abspath(apk1)],
1848 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1849 return("Failed to unpack " + apk1)
1850 if subprocess.call(['jar', 'xf',
1851 os.path.abspath(apk2)],
1852 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1853 return("Failed to unpack " + apk2)
1855 # try to find apktool in the path, if it hasn't been manually configed
1856 if 'apktool' not in config:
1857 tmp = find_command('apktool')
1859 config['apktool'] = tmp
1860 if 'apktool' in config:
1861 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1863 return("Failed to unpack " + apk1)
1864 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1866 return("Failed to unpack " + apk2)
1868 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1869 lines = p.output.splitlines()
1870 if len(lines) != 1 or 'META-INF' not in lines[0]:
1871 meld = find_command('meld')
1872 if meld is not None:
1873 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1874 return("Unexpected diff output - " + p.output)
1876 # since everything verifies, delete the comparison to keep cruft down
1877 shutil.rmtree(apk1dir)
1878 shutil.rmtree(apk2dir)
1880 # If we get here, it seems like they're the same!
1884 def find_command(command):
1885 '''find the full path of a command, or None if it can't be found in the PATH'''
1888 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1890 fpath, fname = os.path.split(command)
1895 for path in os.environ["PATH"].split(os.pathsep):
1896 path = path.strip('"')
1897 exe_file = os.path.join(path, command)
1898 if is_exe(exe_file):
1905 '''generate a random password for when generating keys'''
1906 h = hashlib.sha256()
1907 h.update(os.urandom(16)) # salt
1908 h.update(bytes(socket.getfqdn()))
1909 return h.digest().encode('base64').strip()
1912 def genkeystore(localconfig):
1913 '''Generate a new key with random passwords and add it to new keystore'''
1914 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1915 keystoredir = os.path.dirname(localconfig['keystore'])
1916 if keystoredir is None or keystoredir == '':
1917 keystoredir = os.path.join(os.getcwd(), keystoredir)
1918 if not os.path.exists(keystoredir):
1919 os.makedirs(keystoredir, mode=0o700)
1921 write_password_file("keystorepass", localconfig['keystorepass'])
1922 write_password_file("keypass", localconfig['keypass'])
1923 p = FDroidPopen([config['keytool'], '-genkey',
1924 '-keystore', localconfig['keystore'],
1925 '-alias', localconfig['repo_keyalias'],
1926 '-keyalg', 'RSA', '-keysize', '4096',
1927 '-sigalg', 'SHA256withRSA',
1928 '-validity', '10000',
1929 '-storepass:file', config['keystorepassfile'],
1930 '-keypass:file', config['keypassfile'],
1931 '-dname', localconfig['keydname']])
1932 # TODO keypass should be sent via stdin
1933 if p.returncode != 0:
1934 raise BuildException("Failed to generate key", p.output)
1935 os.chmod(localconfig['keystore'], 0o0600)
1936 # now show the lovely key that was just generated
1937 p = FDroidPopen([config['keytool'], '-list', '-v',
1938 '-keystore', localconfig['keystore'],
1939 '-alias', localconfig['repo_keyalias'],
1940 '-storepass:file', config['keystorepassfile']])
1941 logging.info(p.output.strip() + '\n\n')
1944 def write_to_config(thisconfig, key, value=None):
1945 '''write a key/value to the local config.py'''
1947 origkey = key + '_orig'
1948 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1949 with open('config.py', 'r') as f:
1951 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1952 repl = '\n' + key + ' = "' + value + '"'
1953 data = re.sub(pattern, repl, data)
1954 # if this key is not in the file, append it
1955 if not re.match('\s*' + key + '\s*=\s*"', data):
1957 # make sure the file ends with a carraige return
1958 if not re.match('\n$', data):
1960 with open('config.py', 'w') as f:
1964 def parse_xml(path):
1965 return XMLElementTree.parse(path).getroot()
1968 def string_is_integer(string):
1976 def get_per_app_repos():
1977 '''per-app repos are dirs named with the packageName of a single app'''
1979 # Android packageNames are Java packages, they may contain uppercase or
1980 # lowercase letters ('A' through 'Z'), numbers, and underscores
1981 # ('_'). However, individual package name parts may only start with
1982 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1983 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1986 for root, dirs, files in os.walk(os.getcwd()):
1988 print('checking', root, 'for', d)
1989 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1990 # standard parts of an fdroid repo, so never packageNames
1993 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):