1 # -*- coding: utf-8 -*-
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
36 import xml.etree.ElementTree as XMLElementTree
38 from zipfile import ZipFile
41 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
44 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
53 'sdk_path': "$ANDROID_HOME",
56 'r10e': "$ANDROID_NDK",
58 'build_tools': "23.0.2",
60 '1.7': "/usr/lib/jvm/java-7-openjdk",
66 'accepted_formats': ['txt', 'yaml'],
67 'sync_from_local_copy_dir': False,
68 'per_app_repos': False,
69 'make_current_version_link': True,
70 'current_version_name_source': 'Name',
71 'update_stats': False,
75 'stats_to_carbon': False,
77 'build_server_always': False,
78 'keystore': 'keystore.jks',
79 'smartcardoptions': [],
85 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
86 'repo_name': "My First FDroid Repo Demo",
87 'repo_icon': "fdroid-icon.png",
88 'repo_description': '''
89 This is a repository of apps to be used with FDroid. Applications in this
90 repository are either official binaries built by the original application
91 developers, or are binaries built from source by the admin of f-droid.org
92 using the tools on https://gitlab.com/u/fdroid.
98 def setup_global_opts(parser):
99 parser.add_argument("-v", "--verbose", action="store_true", default=False,
100 help="Spew out even more information than normal")
101 parser.add_argument("-q", "--quiet", action="store_true", default=False,
102 help="Restrict output to warnings and errors")
105 def fill_config_defaults(thisconfig):
106 for k, v in default_config.items():
107 if k not in thisconfig:
110 # Expand paths (~users and $vars)
111 def expand_path(path):
115 path = os.path.expanduser(path)
116 path = os.path.expandvars(path)
121 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
126 thisconfig[k + '_orig'] = v
128 for k in ['ndk_paths', 'java_paths']:
134 thisconfig[k][k2] = exp
135 thisconfig[k][k2 + '_orig'] = v
138 def regsub_file(pattern, repl, path):
139 with open(path, 'r') as f:
141 text = re.sub(pattern, repl, text)
142 with open(path, 'w') as f:
146 def read_config(opts, config_file='config.py'):
147 """Read the repository config
149 The config is read from config_file, which is in the current directory when
150 any of the repo management commands are used.
152 global config, options, env, orig_path
154 if config is not None:
156 if not os.path.isfile(config_file):
157 logging.critical("Missing config file - is this a repo directory?")
164 logging.debug("Reading %s" % config_file)
165 execfile(config_file, config)
167 # smartcardoptions must be a list since its command line args for Popen
168 if 'smartcardoptions' in config:
169 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
170 elif 'keystore' in config and config['keystore'] == 'NONE':
171 # keystore='NONE' means use smartcard, these are required defaults
172 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
173 'SunPKCS11-OpenSC', '-providerClass',
174 'sun.security.pkcs11.SunPKCS11',
175 '-providerArg', 'opensc-fdroid.cfg']
177 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
178 st = os.stat(config_file)
179 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
180 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
182 fill_config_defaults(config)
184 # There is no standard, so just set up the most common environment
187 orig_path = env['PATH']
188 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
189 env[n] = config['sdk_path']
192 cpath = config['java_paths']['1.%s' % v]
194 env['JAVA%s_HOME' % v] = cpath
196 for k in ["keystorepass", "keypass"]:
198 write_password_file(k)
200 for k in ["repo_description", "archive_description"]:
202 config[k] = clean_description(config[k])
204 if 'serverwebroot' in config:
205 if isinstance(config['serverwebroot'], basestring):
206 roots = [config['serverwebroot']]
207 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
208 roots = config['serverwebroot']
210 raise TypeError('only accepts strings, lists, and tuples')
212 for rootstr in roots:
213 # since this is used with rsync, where trailing slashes have
214 # meaning, ensure there is always a trailing slash
215 if rootstr[-1] != '/':
217 rootlist.append(rootstr.replace('//', '/'))
218 config['serverwebroot'] = rootlist
223 def find_sdk_tools_cmd(cmd):
224 '''find a working path to a tool from the Android SDK'''
227 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
228 # try to find a working path to this command, in all the recent possible paths
229 if 'build_tools' in config:
230 build_tools = os.path.join(config['sdk_path'], 'build-tools')
231 # if 'build_tools' was manually set and exists, check only that one
232 configed_build_tools = os.path.join(build_tools, config['build_tools'])
233 if os.path.exists(configed_build_tools):
234 tooldirs.append(configed_build_tools)
236 # no configed version, so hunt known paths for it
237 for f in sorted(os.listdir(build_tools), reverse=True):
238 if os.path.isdir(os.path.join(build_tools, f)):
239 tooldirs.append(os.path.join(build_tools, f))
240 tooldirs.append(build_tools)
241 sdk_tools = os.path.join(config['sdk_path'], 'tools')
242 if os.path.exists(sdk_tools):
243 tooldirs.append(sdk_tools)
244 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
245 if os.path.exists(sdk_platform_tools):
246 tooldirs.append(sdk_platform_tools)
247 tooldirs.append('/usr/bin')
249 if os.path.isfile(os.path.join(d, cmd)):
250 return os.path.join(d, cmd)
251 # did not find the command, exit with error message
252 ensure_build_tools_exists(config)
255 def test_sdk_exists(thisconfig):
256 if 'sdk_path' not in thisconfig:
257 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
260 logging.error("'sdk_path' not set in config.py!")
262 if thisconfig['sdk_path'] == default_config['sdk_path']:
263 logging.error('No Android SDK found!')
264 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
265 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
267 if not os.path.exists(thisconfig['sdk_path']):
268 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
270 if not os.path.isdir(thisconfig['sdk_path']):
271 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
273 for d in ['build-tools', 'platform-tools', 'tools']:
274 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
275 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
276 thisconfig['sdk_path'], d))
281 def ensure_build_tools_exists(thisconfig):
282 if not test_sdk_exists(thisconfig):
284 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
285 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
286 if not os.path.isdir(versioned_build_tools):
287 logging.critical('Android Build Tools path "'
288 + versioned_build_tools + '" does not exist!')
292 def write_password_file(pwtype, password=None):
294 writes out passwords to a protected file instead of passing passwords as
295 command line argments
297 filename = '.fdroid.' + pwtype + '.txt'
298 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
300 os.write(fd, config[pwtype])
302 os.write(fd, password)
304 config[pwtype + 'file'] = filename
307 # Given the arguments in the form of multiple appid:[vc] strings, this returns
308 # a dictionary with the set of vercodes specified for each package.
309 def read_pkg_args(args, allow_vercodes=False):
316 if allow_vercodes and ':' in p:
317 package, vercode = p.split(':')
319 package, vercode = p, None
320 if package not in vercodes:
321 vercodes[package] = [vercode] if vercode else []
323 elif vercode and vercode not in vercodes[package]:
324 vercodes[package] += [vercode] if vercode else []
329 # On top of what read_pkg_args does, this returns the whole app metadata, but
330 # limiting the builds list to the builds matching the vercodes specified.
331 def read_app_args(args, allapps, allow_vercodes=False):
333 vercodes = read_pkg_args(args, allow_vercodes)
339 for appid, app in allapps.iteritems():
340 if appid in vercodes:
343 if len(apps) != len(vercodes):
346 logging.critical("No such package: %s" % p)
347 raise FDroidException("Found invalid app ids in arguments")
349 raise FDroidException("No packages specified")
352 for appid, app in apps.iteritems():
356 app.builds = [b for b in app.builds if b.vercode in vc]
357 if len(app.builds) != len(vercodes[appid]):
359 allvcs = [b.vercode for b in app.builds]
360 for v in vercodes[appid]:
362 logging.critical("No such vercode %s for app %s" % (v, appid))
365 raise FDroidException("Found invalid vercodes for some apps")
370 def get_extension(filename):
371 base, ext = os.path.splitext(filename)
374 return base, ext.lower()[1:]
377 def has_extension(filename, ext):
378 _, f_ext = get_extension(filename)
382 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
385 def clean_description(description):
386 'Remove unneeded newlines and spaces from a block of description text'
388 # this is split up by paragraph to make removing the newlines easier
389 for paragraph in re.split(r'\n\n', description):
390 paragraph = re.sub('\r', '', paragraph)
391 paragraph = re.sub('\n', ' ', paragraph)
392 paragraph = re.sub(' {2,}', ' ', paragraph)
393 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
394 returnstring += paragraph + '\n\n'
395 return returnstring.rstrip('\n')
398 def apknameinfo(filename):
399 filename = os.path.basename(filename)
400 m = apk_regex.match(filename)
402 result = (m.group(1), m.group(2))
403 except AttributeError:
404 raise FDroidException("Invalid apk name: %s" % filename)
408 def getapkname(app, build):
409 return "%s_%s.apk" % (app.id, build.vercode)
412 def getsrcname(app, build):
413 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
425 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
428 def getvcs(vcstype, remote, local):
430 return vcs_git(remote, local)
431 if vcstype == 'git-svn':
432 return vcs_gitsvn(remote, local)
434 return vcs_hg(remote, local)
436 return vcs_bzr(remote, local)
437 if vcstype == 'srclib':
438 if local != os.path.join('build', 'srclib', remote):
439 raise VCSException("Error: srclib paths are hard-coded!")
440 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
442 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
443 raise VCSException("Invalid vcs type " + vcstype)
446 def getsrclibvcs(name):
447 if name not in metadata.srclibs:
448 raise VCSException("Missing srclib " + name)
449 return metadata.srclibs[name]['Repo Type']
454 def __init__(self, remote, local):
456 # svn, git-svn and bzr may require auth
458 if self.repotype() in ('git-svn', 'bzr'):
460 if self.repotype == 'git-svn':
461 raise VCSException("Authentication is not supported for git-svn")
462 self.username, remote = remote.split('@')
463 if ':' not in self.username:
464 raise VCSException("Password required with username")
465 self.username, self.password = self.username.split(':')
469 self.clone_failed = False
470 self.refreshed = False
476 # Take the local repository to a clean version of the given revision, which
477 # is specificed in the VCS's native format. Beforehand, the repository can
478 # be dirty, or even non-existent. If the repository does already exist
479 # locally, it will be updated from the origin, but only once in the
480 # lifetime of the vcs object.
481 # None is acceptable for 'rev' if you know you are cloning a clean copy of
482 # the repo - otherwise it must specify a valid revision.
483 def gotorevision(self, rev, refresh=True):
485 if self.clone_failed:
486 raise VCSException("Downloading the repository already failed once, not trying again.")
488 # The .fdroidvcs-id file for a repo tells us what VCS type
489 # and remote that directory was created from, allowing us to drop it
490 # automatically if either of those things changes.
491 fdpath = os.path.join(self.local, '..',
492 '.fdroidvcs-' + os.path.basename(self.local))
493 cdata = self.repotype() + ' ' + self.remote
496 if os.path.exists(self.local):
497 if os.path.exists(fdpath):
498 with open(fdpath, 'r') as f:
499 fsdata = f.read().strip()
504 logging.info("Repository details for %s changed - deleting" % (
508 logging.info("Repository details for %s missing - deleting" % (
511 shutil.rmtree(self.local)
515 self.refreshed = True
518 self.gotorevisionx(rev)
519 except FDroidException, e:
522 # If necessary, write the .fdroidvcs file.
523 if writeback and not self.clone_failed:
524 with open(fdpath, 'w') as f:
530 # Derived classes need to implement this. It's called once basic checking
531 # has been performend.
532 def gotorevisionx(self, rev):
533 raise VCSException("This VCS type doesn't define gotorevisionx")
535 # Initialise and update submodules
536 def initsubmodules(self):
537 raise VCSException('Submodules not supported for this vcs type')
539 # Get a list of all known tags
541 if not self._gettags:
542 raise VCSException('gettags not supported for this vcs type')
544 for tag in self._gettags():
545 if re.match('[-A-Za-z0-9_. /]+$', tag):
549 def latesttags(self, tags, number):
550 """Get the most recent tags in a given list.
552 :param tags: a list of tags
553 :param number: the number to return
554 :returns: A list containing the most recent tags in the provided
555 list, up to the maximum number given.
557 raise VCSException('latesttags not supported for this vcs type')
559 # Get current commit reference (hash, revision, etc)
561 raise VCSException('getref not supported for this vcs type')
563 # Returns the srclib (name, path) used in setting up the current
574 # If the local directory exists, but is somehow not a git repository, git
575 # will traverse up the directory tree until it finds one that is (i.e.
576 # fdroidserver) and then we'll proceed to destroy it! This is called as
579 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
580 result = p.output.rstrip()
581 if not result.endswith(self.local):
582 raise VCSException('Repository mismatch')
584 def gotorevisionx(self, rev):
585 if not os.path.exists(self.local):
587 p = FDroidPopen(['git', 'clone', self.remote, self.local])
588 if p.returncode != 0:
589 self.clone_failed = True
590 raise VCSException("Git clone failed", p.output)
594 # Discard any working tree changes
595 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
596 'git', 'reset', '--hard'], cwd=self.local, output=False)
597 if p.returncode != 0:
598 raise VCSException("Git reset failed", p.output)
599 # Remove untracked files now, in case they're tracked in the target
600 # revision (it happens!)
601 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
602 'git', 'clean', '-dffx'], cwd=self.local, output=False)
603 if p.returncode != 0:
604 raise VCSException("Git clean failed", p.output)
605 if not self.refreshed:
606 # Get latest commits and tags from remote
607 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
608 if p.returncode != 0:
609 raise VCSException("Git fetch failed", p.output)
610 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
611 if p.returncode != 0:
612 raise VCSException("Git fetch failed", p.output)
613 # Recreate origin/HEAD as git clone would do it, in case it disappeared
614 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
615 if p.returncode != 0:
616 lines = p.output.splitlines()
617 if 'Multiple remote HEAD branches' not in lines[0]:
618 raise VCSException("Git remote set-head failed", p.output)
619 branch = lines[1].split(' ')[-1]
620 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
621 if p2.returncode != 0:
622 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
623 self.refreshed = True
624 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
625 # a github repo. Most of the time this is the same as origin/master.
626 rev = rev or 'origin/HEAD'
627 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
628 if p.returncode != 0:
629 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
630 # Get rid of any uncontrolled files left behind
631 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
632 if p.returncode != 0:
633 raise VCSException("Git clean failed", p.output)
635 def initsubmodules(self):
637 submfile = os.path.join(self.local, '.gitmodules')
638 if not os.path.isfile(submfile):
639 raise VCSException("No git submodules available")
641 # fix submodules not accessible without an account and public key auth
642 with open(submfile, 'r') as f:
643 lines = f.readlines()
644 with open(submfile, 'w') as f:
646 if 'git@github.com' in line:
647 line = line.replace('git@github.com:', 'https://github.com/')
648 if 'git@gitlab.com' in line:
649 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
652 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
653 if p.returncode != 0:
654 raise VCSException("Git submodule sync failed", p.output)
655 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
656 if p.returncode != 0:
657 raise VCSException("Git submodule update failed", p.output)
661 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
662 return p.output.splitlines()
664 def latesttags(self, tags, number):
669 ['git', 'show', '--format=format:%ct', '-s', tag],
670 cwd=self.local, output=False)
671 # Timestamp is on the last line. For a normal tag, it's the only
672 # line, but for annotated tags, the rest of the info precedes it.
673 ts = int(p.output.splitlines()[-1])
676 for _, t in sorted(tl)[-number:]:
681 class vcs_gitsvn(vcs):
686 # If the local directory exists, but is somehow not a git repository, git
687 # will traverse up the directory tree until it finds one that is (i.e.
688 # fdroidserver) and then we'll proceed to destory it! This is called as
691 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
692 result = p.output.rstrip()
693 if not result.endswith(self.local):
694 raise VCSException('Repository mismatch')
696 def gotorevisionx(self, rev):
697 if not os.path.exists(self.local):
699 gitsvn_args = ['git', 'svn', 'clone']
700 if ';' in self.remote:
701 remote_split = self.remote.split(';')
702 for i in remote_split[1:]:
703 if i.startswith('trunk='):
704 gitsvn_args.extend(['-T', i[6:]])
705 elif i.startswith('tags='):
706 gitsvn_args.extend(['-t', i[5:]])
707 elif i.startswith('branches='):
708 gitsvn_args.extend(['-b', i[9:]])
709 gitsvn_args.extend([remote_split[0], self.local])
710 p = FDroidPopen(gitsvn_args, output=False)
711 if p.returncode != 0:
712 self.clone_failed = True
713 raise VCSException("Git svn clone failed", p.output)
715 gitsvn_args.extend([self.remote, self.local])
716 p = FDroidPopen(gitsvn_args, output=False)
717 if p.returncode != 0:
718 self.clone_failed = True
719 raise VCSException("Git svn clone failed", p.output)
723 # Discard any working tree changes
724 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
725 if p.returncode != 0:
726 raise VCSException("Git reset failed", p.output)
727 # Remove untracked files now, in case they're tracked in the target
728 # revision (it happens!)
729 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
730 if p.returncode != 0:
731 raise VCSException("Git clean failed", p.output)
732 if not self.refreshed:
733 # Get new commits, branches and tags from repo
734 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
735 if p.returncode != 0:
736 raise VCSException("Git svn fetch failed")
737 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
738 if p.returncode != 0:
739 raise VCSException("Git svn rebase failed", p.output)
740 self.refreshed = True
742 rev = rev or 'master'
744 nospaces_rev = rev.replace(' ', '%20')
745 # Try finding a svn tag
746 for treeish in ['origin/', '']:
747 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
748 if p.returncode == 0:
750 if p.returncode != 0:
751 # No tag found, normal svn rev translation
752 # Translate svn rev into git format
753 rev_split = rev.split('/')
756 for treeish in ['origin/', '']:
757 if len(rev_split) > 1:
758 treeish += rev_split[0]
759 svn_rev = rev_split[1]
762 # if no branch is specified, then assume trunk (i.e. 'master' branch):
766 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
768 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
769 git_rev = p.output.rstrip()
771 if p.returncode == 0 and git_rev:
774 if p.returncode != 0 or not git_rev:
775 # Try a plain git checkout as a last resort
776 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
777 if p.returncode != 0:
778 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
780 # Check out the git rev equivalent to the svn rev
781 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
782 if p.returncode != 0:
783 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
785 # Get rid of any uncontrolled files left behind
786 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
787 if p.returncode != 0:
788 raise VCSException("Git clean failed", p.output)
792 for treeish in ['origin/', '']:
793 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
799 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
800 if p.returncode != 0:
802 return p.output.strip()
810 def gotorevisionx(self, rev):
811 if not os.path.exists(self.local):
812 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
813 if p.returncode != 0:
814 self.clone_failed = True
815 raise VCSException("Hg clone failed", p.output)
817 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
818 if p.returncode != 0:
819 raise VCSException("Hg status failed", p.output)
820 for line in p.output.splitlines():
821 if not line.startswith('? '):
822 raise VCSException("Unexpected output from hg status -uS: " + line)
823 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
824 if not self.refreshed:
825 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
826 if p.returncode != 0:
827 raise VCSException("Hg pull failed", p.output)
828 self.refreshed = True
830 rev = rev or 'default'
833 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
834 if p.returncode != 0:
835 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
836 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
837 # Also delete untracked files, we have to enable purge extension for that:
838 if "'purge' is provided by the following extension" in p.output:
839 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
840 myfile.write("\n[extensions]\nhgext.purge=\n")
841 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("HG purge failed", p.output)
844 elif p.returncode != 0:
845 raise VCSException("HG purge failed", p.output)
848 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
849 return p.output.splitlines()[1:]
857 def gotorevisionx(self, rev):
858 if not os.path.exists(self.local):
859 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
860 if p.returncode != 0:
861 self.clone_failed = True
862 raise VCSException("Bzr branch failed", p.output)
864 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
865 if p.returncode != 0:
866 raise VCSException("Bzr revert failed", p.output)
867 if not self.refreshed:
868 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("Bzr update failed", p.output)
871 self.refreshed = True
873 revargs = list(['-r', rev] if rev else [])
874 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
875 if p.returncode != 0:
876 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
879 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
880 return [tag.split(' ')[0].strip() for tag in
881 p.output.splitlines()]
884 def unescape_string(string):
887 if string[0] == '"' and string[-1] == '"':
890 return string.replace("\\'", "'")
893 def retrieve_string(app_dir, string, xmlfiles=None):
895 if not string.startswith('@string/'):
896 return unescape_string(string)
901 os.path.join(app_dir, 'res'),
902 os.path.join(app_dir, 'src', 'main', 'res'),
904 for r, d, f in os.walk(res_dir):
905 if os.path.basename(r) == 'values':
906 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
908 name = string[len('@string/'):]
910 def element_content(element):
911 if element.text is None:
913 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
916 for path in xmlfiles:
917 if not os.path.isfile(path):
919 xml = parse_xml(path)
920 element = xml.find('string[@name="' + name + '"]')
921 if element is not None:
922 content = element_content(element)
923 return retrieve_string(app_dir, content, xmlfiles)
928 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
929 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
932 # Return list of existing files that will be used to find the highest vercode
933 def manifest_paths(app_dir, flavours):
935 possible_manifests = \
936 [os.path.join(app_dir, 'AndroidManifest.xml'),
937 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
938 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
939 os.path.join(app_dir, 'build.gradle')]
941 for flavour in flavours:
944 possible_manifests.append(
945 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
947 return [path for path in possible_manifests if os.path.isfile(path)]
950 # Retrieve the package name. Returns the name, or None if not found.
951 def fetch_real_name(app_dir, flavours):
952 for path in manifest_paths(app_dir, flavours):
953 if not has_extension(path, 'xml') or not os.path.isfile(path):
955 logging.debug("fetch_real_name: Checking manifest at " + path)
956 xml = parse_xml(path)
957 app = xml.find('application')
960 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
962 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
963 result = retrieve_string_singleline(app_dir, label)
965 result = result.strip()
970 def get_library_references(root_dir):
972 proppath = os.path.join(root_dir, 'project.properties')
973 if not os.path.isfile(proppath):
975 for line in file(proppath):
976 if not line.startswith('android.library.reference.'):
978 path = line.split('=')[1].strip()
979 relpath = os.path.join(root_dir, path)
980 if not os.path.isdir(relpath):
982 logging.debug("Found subproject at %s" % path)
983 libraries.append(path)
987 def ant_subprojects(root_dir):
988 subprojects = get_library_references(root_dir)
989 for subpath in subprojects:
990 subrelpath = os.path.join(root_dir, subpath)
991 for p in get_library_references(subrelpath):
992 relp = os.path.normpath(os.path.join(subpath, p))
993 if relp not in subprojects:
994 subprojects.insert(0, relp)
998 def remove_debuggable_flags(root_dir):
999 # Remove forced debuggable flags
1000 logging.debug("Removing debuggable flags from %s" % root_dir)
1001 for root, dirs, files in os.walk(root_dir):
1002 if 'AndroidManifest.xml' in files:
1003 regsub_file(r'android:debuggable="[^"]*"',
1005 os.path.join(root, 'AndroidManifest.xml'))
1008 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1009 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1010 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1013 def app_matches_packagename(app, package):
1016 appid = app.UpdateCheckName or app.id
1017 if appid is None or appid == "Ignore":
1019 return appid == package
1022 # Extract some information from the AndroidManifest.xml at the given path.
1023 # Returns (version, vercode, package), any or all of which might be None.
1024 # All values returned are strings.
1025 def parse_androidmanifests(paths, app):
1027 ignoreversions = app.UpdateCheckIgnore
1028 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1031 return (None, None, None)
1039 if not os.path.isfile(path):
1042 logging.debug("Parsing manifest at {0}".format(path))
1043 gradle = has_extension(path, 'gradle')
1049 for line in file(path):
1050 if gradle_comment.match(line):
1052 # Grab first occurence of each to avoid running into
1053 # alternative flavours and builds.
1055 matches = psearch_g(line)
1057 s = matches.group(2)
1058 if app_matches_packagename(app, s):
1061 matches = vnsearch_g(line)
1063 version = matches.group(2)
1065 matches = vcsearch_g(line)
1067 vercode = matches.group(1)
1069 xml = parse_xml(path)
1070 if "package" in xml.attrib:
1071 s = xml.attrib["package"].encode('utf-8')
1072 if app_matches_packagename(app, s):
1074 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1075 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1076 base_dir = os.path.dirname(path)
1077 version = retrieve_string_singleline(base_dir, version)
1078 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1079 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1080 if string_is_integer(a):
1083 # Remember package name, may be defined separately from version+vercode
1085 package = max_package
1087 logging.debug("..got package={0}, version={1}, vercode={2}"
1088 .format(package, version, vercode))
1090 # Always grab the package name and version name in case they are not
1091 # together with the highest version code
1092 if max_package is None and package is not None:
1093 max_package = package
1094 if max_version is None and version is not None:
1095 max_version = version
1097 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1098 if not ignoresearch or not ignoresearch(version):
1099 if version is not None:
1100 max_version = version
1101 if vercode is not None:
1102 max_vercode = vercode
1103 if package is not None:
1104 max_package = package
1106 max_version = "Ignore"
1108 if max_version is None:
1109 max_version = "Unknown"
1111 if max_package and not is_valid_package_name(max_package):
1112 raise FDroidException("Invalid package name {0}".format(max_package))
1114 return (max_version, max_vercode, max_package)
1117 def is_valid_package_name(name):
1118 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1121 class FDroidException(Exception):
1123 def __init__(self, value, detail=None):
1125 self.detail = detail
1127 def shortened_detail(self):
1128 if len(self.detail) < 16000:
1130 return '[...]\n' + self.detail[-16000:]
1132 def get_wikitext(self):
1133 ret = repr(self.value) + "\n"
1136 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1142 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1146 class VCSException(FDroidException):
1150 class BuildException(FDroidException):
1154 # Get the specified source library.
1155 # Returns the path to it. Normally this is the path to be used when referencing
1156 # it, which may be a subdirectory of the actual project. If you want the base
1157 # directory of the project, pass 'basepath=True'.
1158 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1159 raw=False, prepare=True, preponly=False, refresh=True):
1167 name, ref = spec.split('@')
1169 number, name = name.split(':', 1)
1171 name, subdir = name.split('/', 1)
1173 if name not in metadata.srclibs:
1174 raise VCSException('srclib ' + name + ' not found.')
1176 srclib = metadata.srclibs[name]
1178 sdir = os.path.join(srclib_dir, name)
1181 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1182 vcs.srclib = (name, number, sdir)
1184 vcs.gotorevision(ref, refresh)
1191 libdir = os.path.join(sdir, subdir)
1192 elif srclib["Subdir"]:
1193 for subdir in srclib["Subdir"]:
1194 libdir_candidate = os.path.join(sdir, subdir)
1195 if os.path.exists(libdir_candidate):
1196 libdir = libdir_candidate
1202 remove_signing_keys(sdir)
1203 remove_debuggable_flags(sdir)
1207 if srclib["Prepare"]:
1208 cmd = replace_config_vars(srclib["Prepare"], None)
1210 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1211 if p.returncode != 0:
1212 raise BuildException("Error running prepare command for srclib %s"
1218 return (name, number, libdir)
1220 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1223 # Prepare the source code for a particular build
1224 # 'vcs' - the appropriate vcs object for the application
1225 # 'app' - the application details from the metadata
1226 # 'build' - the build details from the metadata
1227 # 'build_dir' - the path to the build directory, usually
1229 # 'srclib_dir' - the path to the source libraries directory, usually
1231 # 'extlib_dir' - the path to the external libraries directory, usually
1233 # Returns the (root, srclibpaths) where:
1234 # 'root' is the root directory, which may be the same as 'build_dir' or may
1235 # be a subdirectory of it.
1236 # 'srclibpaths' is information on the srclibs being used
1237 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1239 # Optionally, the actual app source can be in a subdirectory
1241 root_dir = os.path.join(build_dir, build.subdir)
1243 root_dir = build_dir
1245 # Get a working copy of the right revision
1246 logging.info("Getting source for revision " + build.commit)
1247 vcs.gotorevision(build.commit, refresh)
1249 # Initialise submodules if required
1250 if build.submodules:
1251 logging.info("Initialising submodules")
1252 vcs.initsubmodules()
1254 # Check that a subdir (if we're using one) exists. This has to happen
1255 # after the checkout, since it might not exist elsewhere
1256 if not os.path.exists(root_dir):
1257 raise BuildException('Missing subdir ' + root_dir)
1259 # Run an init command if one is required
1261 cmd = replace_config_vars(build.init, build)
1262 logging.info("Running 'init' commands in %s" % root_dir)
1264 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1265 if p.returncode != 0:
1266 raise BuildException("Error running init command for %s:%s" %
1267 (app.id, build.version), p.output)
1269 # Apply patches if any
1271 logging.info("Applying patches")
1272 for patch in build.patch:
1273 patch = patch.strip()
1274 logging.info("Applying " + patch)
1275 patch_path = os.path.join('metadata', app.id, patch)
1276 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1277 if p.returncode != 0:
1278 raise BuildException("Failed to apply patch %s" % patch_path)
1280 # Get required source libraries
1283 logging.info("Collecting source libraries")
1284 for lib in build.srclibs:
1285 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1287 for name, number, libpath in srclibpaths:
1288 place_srclib(root_dir, int(number) if number else None, libpath)
1290 basesrclib = vcs.getsrclib()
1291 # If one was used for the main source, add that too.
1293 srclibpaths.append(basesrclib)
1295 # Update the local.properties file
1296 localprops = [os.path.join(build_dir, 'local.properties')]
1298 parts = build.subdir.split(os.sep)
1301 cur = os.path.join(cur, d)
1302 localprops += [os.path.join(cur, 'local.properties')]
1303 for path in localprops:
1305 if os.path.isfile(path):
1306 logging.info("Updating local.properties file at %s" % path)
1307 with open(path, 'r') as f:
1311 logging.info("Creating local.properties file at %s" % path)
1312 # Fix old-fashioned 'sdk-location' by copying
1313 # from sdk.dir, if necessary
1315 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1316 re.S | re.M).group(1)
1317 props += "sdk-location=%s\n" % sdkloc
1319 props += "sdk.dir=%s\n" % config['sdk_path']
1320 props += "sdk-location=%s\n" % config['sdk_path']
1321 ndk_path = build.ndk_path()
1324 props += "ndk.dir=%s\n" % ndk_path
1325 props += "ndk-location=%s\n" % ndk_path
1326 # Add java.encoding if necessary
1328 props += "java.encoding=%s\n" % build.encoding
1329 with open(path, 'w') as f:
1333 if build.method() == 'gradle':
1334 flavours = build.gradle
1337 n = build.target.split('-')[1]
1338 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1339 r'compileSdkVersion %s' % n,
1340 os.path.join(root_dir, 'build.gradle'))
1342 # Remove forced debuggable flags
1343 remove_debuggable_flags(root_dir)
1345 # Insert version code and number into the manifest if necessary
1346 if build.forceversion:
1347 logging.info("Changing the version name")
1348 for path in manifest_paths(root_dir, flavours):
1349 if not os.path.isfile(path):
1351 if has_extension(path, 'xml'):
1352 regsub_file(r'android:versionName="[^"]*"',
1353 r'android:versionName="%s"' % build.version,
1355 elif has_extension(path, 'gradle'):
1356 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1357 r"""\1versionName '%s'""" % build.version,
1360 if build.forcevercode:
1361 logging.info("Changing the version code")
1362 for path in manifest_paths(root_dir, flavours):
1363 if not os.path.isfile(path):
1365 if has_extension(path, 'xml'):
1366 regsub_file(r'android:versionCode="[^"]*"',
1367 r'android:versionCode="%s"' % build.vercode,
1369 elif has_extension(path, 'gradle'):
1370 regsub_file(r'versionCode[ =]+[0-9]+',
1371 r'versionCode %s' % build.vercode,
1374 # Delete unwanted files
1376 logging.info("Removing specified files")
1377 for part in getpaths(build_dir, build.rm):
1378 dest = os.path.join(build_dir, part)
1379 logging.info("Removing {0}".format(part))
1380 if os.path.lexists(dest):
1381 if os.path.islink(dest):
1382 FDroidPopen(['unlink', dest], output=False)
1384 FDroidPopen(['rm', '-rf', dest], output=False)
1386 logging.info("...but it didn't exist")
1388 remove_signing_keys(build_dir)
1390 # Add required external libraries
1392 logging.info("Collecting prebuilt libraries")
1393 libsdir = os.path.join(root_dir, 'libs')
1394 if not os.path.exists(libsdir):
1396 for lib in build.extlibs:
1398 logging.info("...installing extlib {0}".format(lib))
1399 libf = os.path.basename(lib)
1400 libsrc = os.path.join(extlib_dir, lib)
1401 if not os.path.exists(libsrc):
1402 raise BuildException("Missing extlib file {0}".format(libsrc))
1403 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1405 # Run a pre-build command if one is required
1407 logging.info("Running 'prebuild' commands in %s" % root_dir)
1409 cmd = replace_config_vars(build.prebuild, build)
1411 # Substitute source library paths into prebuild commands
1412 for name, number, libpath in srclibpaths:
1413 libpath = os.path.relpath(libpath, root_dir)
1414 cmd = cmd.replace('$$' + name + '$$', libpath)
1416 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1417 if p.returncode != 0:
1418 raise BuildException("Error running prebuild command for %s:%s" %
1419 (app.id, build.version), p.output)
1421 # Generate (or update) the ant build file, build.xml...
1422 if build.method() == 'ant' and build.update != ['no']:
1423 parms = ['android', 'update', 'lib-project']
1424 lparms = ['android', 'update', 'project']
1427 parms += ['-t', build.target]
1428 lparms += ['-t', build.target]
1430 update_dirs = build.update
1432 update_dirs = ant_subprojects(root_dir) + ['.']
1434 for d in update_dirs:
1435 subdir = os.path.join(root_dir, d)
1437 logging.debug("Updating main project")
1438 cmd = parms + ['-p', d]
1440 logging.debug("Updating subproject %s" % d)
1441 cmd = lparms + ['-p', d]
1442 p = SdkToolsPopen(cmd, cwd=root_dir)
1443 # Check to see whether an error was returned without a proper exit
1444 # code (this is the case for the 'no target set or target invalid'
1446 if p.returncode != 0 or p.output.startswith("Error: "):
1447 raise BuildException("Failed to update project at %s" % d, p.output)
1448 # Clean update dirs via ant
1450 logging.info("Cleaning subproject %s" % d)
1451 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1453 return (root_dir, srclibpaths)
1456 # Extend via globbing the paths from a field and return them as a map from
1457 # original path to resulting paths
1458 def getpaths_map(build_dir, globpaths):
1462 full_path = os.path.join(build_dir, p)
1463 full_path = os.path.normpath(full_path)
1464 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1466 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1470 # Extend via globbing the paths from a field and return them as a set
1471 def getpaths(build_dir, globpaths):
1472 paths_map = getpaths_map(build_dir, globpaths)
1474 for k, v in paths_map.iteritems():
1481 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1487 self.path = os.path.join('stats', 'known_apks.txt')
1489 if os.path.isfile(self.path):
1490 for line in file(self.path):
1491 t = line.rstrip().split(' ')
1493 self.apks[t[0]] = (t[1], None)
1495 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1496 self.changed = False
1498 def writeifchanged(self):
1499 if not self.changed:
1502 if not os.path.exists('stats'):
1506 for apk, app in self.apks.iteritems():
1508 line = apk + ' ' + appid
1510 line += ' ' + time.strftime('%Y-%m-%d', added)
1513 with open(self.path, 'w') as f:
1514 for line in sorted(lst, key=natural_key):
1515 f.write(line + '\n')
1517 # Record an apk (if it's new, otherwise does nothing)
1518 # Returns the date it was added.
1519 def recordapk(self, apk, app):
1520 if apk not in self.apks:
1521 self.apks[apk] = (app, time.gmtime(time.time()))
1523 _, added = self.apks[apk]
1526 # Look up information - given the 'apkname', returns (app id, date added/None).
1527 # Or returns None for an unknown apk.
1528 def getapp(self, apkname):
1529 if apkname in self.apks:
1530 return self.apks[apkname]
1533 # Get the most recent 'num' apps added to the repo, as a list of package ids
1534 # with the most recent first.
1535 def getlatest(self, num):
1537 for apk, app in self.apks.iteritems():
1541 if apps[appid] > added:
1545 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1546 lst = [app for app, _ in sortedapps]
1551 def isApkDebuggable(apkfile, config):
1552 """Returns True if the given apk file is debuggable
1554 :param apkfile: full path to the apk to check"""
1556 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1558 if p.returncode != 0:
1559 logging.critical("Failed to get apk manifest information")
1561 for line in p.output.splitlines():
1562 if 'android:debuggable' in line and not line.endswith('0x0'):
1572 def SdkToolsPopen(commands, cwd=None, output=True):
1574 if cmd not in config:
1575 config[cmd] = find_sdk_tools_cmd(commands[0])
1576 abscmd = config[cmd]
1578 logging.critical("Could not find '%s' on your system" % cmd)
1580 return FDroidPopen([abscmd] + commands[1:],
1581 cwd=cwd, output=output)
1584 def FDroidPopen(commands, cwd=None, output=True):
1586 Run a command and capture the possibly huge output.
1588 :param commands: command and argument list like in subprocess.Popen
1589 :param cwd: optionally specifies a working directory
1590 :returns: A PopenResult.
1596 cwd = os.path.normpath(cwd)
1597 logging.debug("Directory: %s" % cwd)
1598 logging.debug("> %s" % ' '.join(commands))
1600 result = PopenResult()
1603 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1604 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1606 raise BuildException("OSError while trying to execute " +
1607 ' '.join(commands) + ': ' + str(e))
1609 stdout_queue = Queue.Queue()
1610 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1612 # Check the queue for output (until there is no more to get)
1613 while not stdout_reader.eof():
1614 while not stdout_queue.empty():
1615 line = stdout_queue.get()
1616 if output and options.verbose:
1617 # Output directly to console
1618 sys.stderr.write(line)
1620 result.output += line
1624 result.returncode = p.wait()
1628 gradle_comment = re.compile(r'[ ]*//')
1629 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1630 gradle_line_matches = [
1631 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1632 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1633 re.compile(r'.*variant\.outputFile = .*'),
1634 re.compile(r'.*output\.outputFile = .*'),
1635 re.compile(r'.*\.readLine\(.*'),
1639 def remove_signing_keys(build_dir):
1640 for root, dirs, files in os.walk(build_dir):
1641 if 'build.gradle' in files:
1642 path = os.path.join(root, 'build.gradle')
1644 with open(path, "r") as o:
1645 lines = o.readlines()
1651 with open(path, "w") as o:
1652 while i < len(lines):
1655 while line.endswith('\\\n'):
1656 line = line.rstrip('\\\n') + lines[i]
1659 if gradle_comment.match(line):
1664 opened += line.count('{')
1665 opened -= line.count('}')
1668 if gradle_signing_configs.match(line):
1673 if any(s.match(line) for s in gradle_line_matches):
1681 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1684 'project.properties',
1686 'default.properties',
1687 'ant.properties', ]:
1688 if propfile in files:
1689 path = os.path.join(root, propfile)
1691 with open(path, "r") as o:
1692 lines = o.readlines()
1696 with open(path, "w") as o:
1698 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1705 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1708 def reset_env_path():
1709 global env, orig_path
1710 env['PATH'] = orig_path
1713 def add_to_env_path(path):
1715 paths = env['PATH'].split(os.pathsep)
1719 env['PATH'] = os.pathsep.join(paths)
1722 def replace_config_vars(cmd, build):
1724 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1725 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1726 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1727 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1728 if build is not None:
1729 cmd = cmd.replace('$$COMMIT$$', build.commit)
1730 cmd = cmd.replace('$$VERSION$$', build.version)
1731 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1735 def place_srclib(root_dir, number, libpath):
1738 relpath = os.path.relpath(libpath, root_dir)
1739 proppath = os.path.join(root_dir, 'project.properties')
1742 if os.path.isfile(proppath):
1743 with open(proppath, "r") as o:
1744 lines = o.readlines()
1746 with open(proppath, "w") as o:
1749 if line.startswith('android.library.reference.%d=' % number):
1750 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1755 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1757 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1760 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1761 """Verify that two apks are the same
1763 One of the inputs is signed, the other is unsigned. The signature metadata
1764 is transferred from the signed to the unsigned apk, and then jarsigner is
1765 used to verify that the signature from the signed apk is also varlid for
1767 :param signed_apk: Path to a signed apk file
1768 :param unsigned_apk: Path to an unsigned apk file expected to match it
1769 :param tmp_dir: Path to directory for temporary files
1770 :returns: None if the verification is successful, otherwise a string
1771 describing what went wrong.
1773 with ZipFile(signed_apk) as signed_apk_as_zip:
1774 meta_inf_files = ['META-INF/MANIFEST.MF']
1775 for f in signed_apk_as_zip.namelist():
1776 if apk_sigfile.match(f):
1777 meta_inf_files.append(f)
1778 if len(meta_inf_files) < 3:
1779 return "Signature files missing from {0}".format(signed_apk)
1780 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1781 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1782 for meta_inf_file in meta_inf_files:
1783 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1785 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1786 logging.info("...NOT verified - {0}".format(signed_apk))
1787 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1788 logging.info("...successfully verified")
1791 apk_badchars = re.compile('''[/ :;'"]''')
1794 def compare_apks(apk1, apk2, tmp_dir):
1797 Returns None if the apk content is the same (apart from the signing key),
1798 otherwise a string describing what's different, or what went wrong when
1799 trying to do the comparison.
1802 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1803 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1804 for d in [apk1dir, apk2dir]:
1805 if os.path.exists(d):
1808 os.mkdir(os.path.join(d, 'jar-xf'))
1810 if subprocess.call(['jar', 'xf',
1811 os.path.abspath(apk1)],
1812 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1813 return("Failed to unpack " + apk1)
1814 if subprocess.call(['jar', 'xf',
1815 os.path.abspath(apk2)],
1816 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1817 return("Failed to unpack " + apk2)
1819 # try to find apktool in the path, if it hasn't been manually configed
1820 if 'apktool' not in config:
1821 tmp = find_command('apktool')
1823 config['apktool'] = tmp
1824 if 'apktool' in config:
1825 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1827 return("Failed to unpack " + apk1)
1828 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1830 return("Failed to unpack " + apk2)
1832 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1833 lines = p.output.splitlines()
1834 if len(lines) != 1 or 'META-INF' not in lines[0]:
1835 meld = find_command('meld')
1836 if meld is not None:
1837 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1838 return("Unexpected diff output - " + p.output)
1840 # since everything verifies, delete the comparison to keep cruft down
1841 shutil.rmtree(apk1dir)
1842 shutil.rmtree(apk2dir)
1844 # If we get here, it seems like they're the same!
1848 def find_command(command):
1849 '''find the full path of a command, or None if it can't be found in the PATH'''
1852 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1854 fpath, fname = os.path.split(command)
1859 for path in os.environ["PATH"].split(os.pathsep):
1860 path = path.strip('"')
1861 exe_file = os.path.join(path, command)
1862 if is_exe(exe_file):
1869 '''generate a random password for when generating keys'''
1870 h = hashlib.sha256()
1871 h.update(os.urandom(16)) # salt
1872 h.update(bytes(socket.getfqdn()))
1873 return h.digest().encode('base64').strip()
1876 def genkeystore(localconfig):
1877 '''Generate a new key with random passwords and add it to new keystore'''
1878 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1879 keystoredir = os.path.dirname(localconfig['keystore'])
1880 if keystoredir is None or keystoredir == '':
1881 keystoredir = os.path.join(os.getcwd(), keystoredir)
1882 if not os.path.exists(keystoredir):
1883 os.makedirs(keystoredir, mode=0o700)
1885 write_password_file("keystorepass", localconfig['keystorepass'])
1886 write_password_file("keypass", localconfig['keypass'])
1887 p = FDroidPopen(['keytool', '-genkey',
1888 '-keystore', localconfig['keystore'],
1889 '-alias', localconfig['repo_keyalias'],
1890 '-keyalg', 'RSA', '-keysize', '4096',
1891 '-sigalg', 'SHA256withRSA',
1892 '-validity', '10000',
1893 '-storepass:file', config['keystorepassfile'],
1894 '-keypass:file', config['keypassfile'],
1895 '-dname', localconfig['keydname']])
1896 # TODO keypass should be sent via stdin
1897 if p.returncode != 0:
1898 raise BuildException("Failed to generate key", p.output)
1899 os.chmod(localconfig['keystore'], 0o0600)
1900 # now show the lovely key that was just generated
1901 p = FDroidPopen(['keytool', '-list', '-v',
1902 '-keystore', localconfig['keystore'],
1903 '-alias', localconfig['repo_keyalias'],
1904 '-storepass:file', config['keystorepassfile']])
1905 logging.info(p.output.strip() + '\n\n')
1908 def write_to_config(thisconfig, key, value=None):
1909 '''write a key/value to the local config.py'''
1911 origkey = key + '_orig'
1912 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1913 with open('config.py', 'r') as f:
1915 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1916 repl = '\n' + key + ' = "' + value + '"'
1917 data = re.sub(pattern, repl, data)
1918 # if this key is not in the file, append it
1919 if not re.match('\s*' + key + '\s*=\s*"', data):
1921 # make sure the file ends with a carraige return
1922 if not re.match('\n$', data):
1924 with open('config.py', 'w') as f:
1928 def parse_xml(path):
1929 return XMLElementTree.parse(path).getroot()
1932 def string_is_integer(string):
1940 def get_per_app_repos():
1941 '''per-app repos are dirs named with the packageName of a single app'''
1943 # Android packageNames are Java packages, they may contain uppercase or
1944 # lowercase letters ('A' through 'Z'), numbers, and underscores
1945 # ('_'). However, individual package name parts may only start with
1946 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1947 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1950 for root, dirs, files in os.walk(os.getcwd()):
1952 print 'checking', root, 'for', d
1953 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1954 # standard parts of an fdroid repo, so never packageNames
1957 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):