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.
37 import xml.etree.ElementTree as XMLElementTree
39 from distutils.version import LooseVersion
40 from zipfile import ZipFile
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.0",
62 'sync_from_local_copy_dir': False,
63 'per_app_repos': False,
64 'make_current_version_link': True,
65 'current_version_name_source': 'Name',
66 'update_stats': False,
70 'stats_to_carbon': False,
72 'build_server_always': False,
73 'keystore': 'keystore.jks',
74 'smartcardoptions': [],
80 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
81 'repo_name': "My First FDroid Repo Demo",
82 'repo_icon': "fdroid-icon.png",
83 'repo_description': '''
84 This is a repository of apps to be used with FDroid. Applications in this
85 repository are either official binaries built by the original application
86 developers, or are binaries built from source by the admin of f-droid.org
87 using the tools on https://gitlab.com/u/fdroid.
93 def fill_config_defaults(thisconfig):
94 for k, v in default_config.items():
95 if k not in thisconfig:
98 # Expand paths (~users and $vars)
99 def expand_path(path):
103 path = os.path.expanduser(path)
104 path = os.path.expandvars(path)
109 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
114 thisconfig[k + '_orig'] = v
116 for k in ['ndk_paths']:
122 thisconfig[k][k2] = exp
123 thisconfig[k][k2 + '_orig'] = v
126 def regsub_file(pattern, repl, path):
127 with open(path, 'r') as f:
129 text = re.sub(pattern, repl, text)
130 with open(path, 'w') as f:
134 def read_config(opts, config_file='config.py'):
135 """Read the repository config
137 The config is read from config_file, which is in the current directory when
138 any of the repo management commands are used.
140 global config, options, env, orig_path
142 if config is not None:
144 if not os.path.isfile(config_file):
145 logging.critical("Missing config file - is this a repo directory?")
152 logging.debug("Reading %s" % config_file)
153 execfile(config_file, config)
155 # smartcardoptions must be a list since its command line args for Popen
156 if 'smartcardoptions' in config:
157 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
158 elif 'keystore' in config and config['keystore'] == 'NONE':
159 # keystore='NONE' means use smartcard, these are required defaults
160 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
161 'SunPKCS11-OpenSC', '-providerClass',
162 'sun.security.pkcs11.SunPKCS11',
163 '-providerArg', 'opensc-fdroid.cfg']
165 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
166 st = os.stat(config_file)
167 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
168 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
170 fill_config_defaults(config)
172 # There is no standard, so just set up the most common environment
175 orig_path = env['PATH']
176 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
177 env[n] = config['sdk_path']
179 for k in ["keystorepass", "keypass"]:
181 write_password_file(k)
183 for k in ["repo_description", "archive_description"]:
185 config[k] = clean_description(config[k])
187 if 'serverwebroot' in config:
188 if isinstance(config['serverwebroot'], basestring):
189 roots = [config['serverwebroot']]
190 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
191 roots = config['serverwebroot']
193 raise TypeError('only accepts strings, lists, and tuples')
195 for rootstr in roots:
196 # since this is used with rsync, where trailing slashes have
197 # meaning, ensure there is always a trailing slash
198 if rootstr[-1] != '/':
200 rootlist.append(rootstr.replace('//', '/'))
201 config['serverwebroot'] = rootlist
206 def get_ndk_path(version):
208 version = 'r10e' # falls back to latest
209 paths = config['ndk_paths']
210 if version not in paths:
212 return paths[version] or ''
215 def find_sdk_tools_cmd(cmd):
216 '''find a working path to a tool from the Android SDK'''
219 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
220 # try to find a working path to this command, in all the recent possible paths
221 if 'build_tools' in config:
222 build_tools = os.path.join(config['sdk_path'], 'build-tools')
223 # if 'build_tools' was manually set and exists, check only that one
224 configed_build_tools = os.path.join(build_tools, config['build_tools'])
225 if os.path.exists(configed_build_tools):
226 tooldirs.append(configed_build_tools)
228 # no configed version, so hunt known paths for it
229 for f in sorted(os.listdir(build_tools), reverse=True):
230 if os.path.isdir(os.path.join(build_tools, f)):
231 tooldirs.append(os.path.join(build_tools, f))
232 tooldirs.append(build_tools)
233 sdk_tools = os.path.join(config['sdk_path'], 'tools')
234 if os.path.exists(sdk_tools):
235 tooldirs.append(sdk_tools)
236 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
237 if os.path.exists(sdk_platform_tools):
238 tooldirs.append(sdk_platform_tools)
239 tooldirs.append('/usr/bin')
241 if os.path.isfile(os.path.join(d, cmd)):
242 return os.path.join(d, cmd)
243 # did not find the command, exit with error message
244 ensure_build_tools_exists(config)
247 def test_sdk_exists(thisconfig):
248 if 'sdk_path' not in thisconfig:
249 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
252 logging.error("'sdk_path' not set in config.py!")
254 if thisconfig['sdk_path'] == default_config['sdk_path']:
255 logging.error('No Android SDK found!')
256 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
257 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
259 if not os.path.exists(thisconfig['sdk_path']):
260 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
262 if not os.path.isdir(thisconfig['sdk_path']):
263 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
265 for d in ['build-tools', 'platform-tools', 'tools']:
266 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
267 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
268 thisconfig['sdk_path'], d))
273 def ensure_build_tools_exists(thisconfig):
274 if not test_sdk_exists(thisconfig):
276 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
277 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
278 if not os.path.isdir(versioned_build_tools):
279 logging.critical('Android Build Tools path "'
280 + versioned_build_tools + '" does not exist!')
284 def write_password_file(pwtype, password=None):
286 writes out passwords to a protected file instead of passing passwords as
287 command line argments
289 filename = '.fdroid.' + pwtype + '.txt'
290 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
292 os.write(fd, config[pwtype])
294 os.write(fd, password)
296 config[pwtype + 'file'] = filename
299 # Given the arguments in the form of multiple appid:[vc] strings, this returns
300 # a dictionary with the set of vercodes specified for each package.
301 def read_pkg_args(args, allow_vercodes=False):
308 if allow_vercodes and ':' in p:
309 package, vercode = p.split(':')
311 package, vercode = p, None
312 if package not in vercodes:
313 vercodes[package] = [vercode] if vercode else []
315 elif vercode and vercode not in vercodes[package]:
316 vercodes[package] += [vercode] if vercode else []
321 # On top of what read_pkg_args does, this returns the whole app metadata, but
322 # limiting the builds list to the builds matching the vercodes specified.
323 def read_app_args(args, allapps, allow_vercodes=False):
325 vercodes = read_pkg_args(args, allow_vercodes)
331 for appid, app in allapps.iteritems():
332 if appid in vercodes:
335 if len(apps) != len(vercodes):
338 logging.critical("No such package: %s" % p)
339 raise FDroidException("Found invalid app ids in arguments")
341 raise FDroidException("No packages specified")
344 for appid, app in apps.iteritems():
348 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
349 if len(app['builds']) != len(vercodes[appid]):
351 allvcs = [b['vercode'] for b in app['builds']]
352 for v in vercodes[appid]:
354 logging.critical("No such vercode %s for app %s" % (v, appid))
357 raise FDroidException("Found invalid vercodes for some apps")
362 def has_extension(filename, extension):
363 name, ext = os.path.splitext(filename)
364 ext = ext.lower()[1:]
365 return ext == extension
370 def clean_description(description):
371 'Remove unneeded newlines and spaces from a block of description text'
373 # this is split up by paragraph to make removing the newlines easier
374 for paragraph in re.split(r'\n\n', description):
375 paragraph = re.sub('\r', '', paragraph)
376 paragraph = re.sub('\n', ' ', paragraph)
377 paragraph = re.sub(' {2,}', ' ', paragraph)
378 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
379 returnstring += paragraph + '\n\n'
380 return returnstring.rstrip('\n')
383 def apknameinfo(filename):
385 filename = os.path.basename(filename)
386 if apk_regex is None:
387 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
388 m = apk_regex.match(filename)
390 result = (m.group(1), m.group(2))
391 except AttributeError:
392 raise FDroidException("Invalid apk name: %s" % filename)
396 def getapkname(app, build):
397 return "%s_%s.apk" % (app['id'], build['vercode'])
400 def getsrcname(app, build):
401 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
408 return app['Auto Name']
413 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
416 def getvcs(vcstype, remote, local):
418 return vcs_git(remote, local)
419 if vcstype == 'git-svn':
420 return vcs_gitsvn(remote, local)
422 return vcs_hg(remote, local)
424 return vcs_bzr(remote, local)
425 if vcstype == 'srclib':
426 if local != os.path.join('build', 'srclib', remote):
427 raise VCSException("Error: srclib paths are hard-coded!")
428 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
430 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
431 raise VCSException("Invalid vcs type " + vcstype)
434 def getsrclibvcs(name):
435 if name not in metadata.srclibs:
436 raise VCSException("Missing srclib " + name)
437 return metadata.srclibs[name]['Repo Type']
442 def __init__(self, remote, local):
444 # svn, git-svn and bzr may require auth
446 if self.repotype() in ('git-svn', 'bzr'):
448 if self.repotype == 'git-svn':
449 raise VCSException("Authentication is not supported for git-svn")
450 self.username, remote = remote.split('@')
451 if ':' not in self.username:
452 raise VCSException("Password required with username")
453 self.username, self.password = self.username.split(':')
457 self.clone_failed = False
458 self.refreshed = False
464 # Take the local repository to a clean version of the given revision, which
465 # is specificed in the VCS's native format. Beforehand, the repository can
466 # be dirty, or even non-existent. If the repository does already exist
467 # locally, it will be updated from the origin, but only once in the
468 # lifetime of the vcs object.
469 # None is acceptable for 'rev' if you know you are cloning a clean copy of
470 # the repo - otherwise it must specify a valid revision.
471 def gotorevision(self, rev, refresh=True):
473 if self.clone_failed:
474 raise VCSException("Downloading the repository already failed once, not trying again.")
476 # The .fdroidvcs-id file for a repo tells us what VCS type
477 # and remote that directory was created from, allowing us to drop it
478 # automatically if either of those things changes.
479 fdpath = os.path.join(self.local, '..',
480 '.fdroidvcs-' + os.path.basename(self.local))
481 cdata = self.repotype() + ' ' + self.remote
484 if os.path.exists(self.local):
485 if os.path.exists(fdpath):
486 with open(fdpath, 'r') as f:
487 fsdata = f.read().strip()
492 logging.info("Repository details for %s changed - deleting" % (
496 logging.info("Repository details for %s missing - deleting" % (
499 shutil.rmtree(self.local)
503 self.refreshed = True
506 self.gotorevisionx(rev)
507 except FDroidException, e:
510 # If necessary, write the .fdroidvcs file.
511 if writeback and not self.clone_failed:
512 with open(fdpath, 'w') as f:
518 # Derived classes need to implement this. It's called once basic checking
519 # has been performend.
520 def gotorevisionx(self, rev):
521 raise VCSException("This VCS type doesn't define gotorevisionx")
523 # Initialise and update submodules
524 def initsubmodules(self):
525 raise VCSException('Submodules not supported for this vcs type')
527 # Get a list of all known tags
529 if not self._gettags:
530 raise VCSException('gettags not supported for this vcs type')
532 for tag in self._gettags():
533 if re.match('[-A-Za-z0-9_. ]+$', tag):
537 def latesttags(self, tags, number):
538 """Get the most recent tags in a given list.
540 :param tags: a list of tags
541 :param number: the number to return
542 :returns: A list containing the most recent tags in the provided
543 list, up to the maximum number given.
545 raise VCSException('latesttags not supported for this vcs type')
547 # Get current commit reference (hash, revision, etc)
549 raise VCSException('getref not supported for this vcs type')
551 # Returns the srclib (name, path) used in setting up the current
562 # If the local directory exists, but is somehow not a git repository, git
563 # will traverse up the directory tree until it finds one that is (i.e.
564 # fdroidserver) and then we'll proceed to destroy it! This is called as
567 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
568 result = p.output.rstrip()
569 if not result.endswith(self.local):
570 raise VCSException('Repository mismatch')
572 def gotorevisionx(self, rev):
573 if not os.path.exists(self.local):
575 p = FDroidPopen(['git', 'clone', self.remote, self.local])
576 if p.returncode != 0:
577 self.clone_failed = True
578 raise VCSException("Git clone failed", p.output)
582 # Discard any working tree changes
583 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
584 'git', 'reset', '--hard'], cwd=self.local, output=False)
585 if p.returncode != 0:
586 raise VCSException("Git reset failed", p.output)
587 # Remove untracked files now, in case they're tracked in the target
588 # revision (it happens!)
589 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
590 'git', 'clean', '-dffx'], cwd=self.local, output=False)
591 if p.returncode != 0:
592 raise VCSException("Git clean failed", p.output)
593 if not self.refreshed:
594 # Get latest commits and tags from remote
595 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
596 if p.returncode != 0:
597 raise VCSException("Git fetch failed", p.output)
598 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
599 if p.returncode != 0:
600 raise VCSException("Git fetch failed", p.output)
601 # Recreate origin/HEAD as git clone would do it, in case it disappeared
602 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
603 if p.returncode != 0:
604 lines = p.output.splitlines()
605 if 'Multiple remote HEAD branches' not in lines[0]:
606 raise VCSException("Git remote set-head failed", p.output)
607 branch = lines[1].split(' ')[-1]
608 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
609 if p2.returncode != 0:
610 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
611 self.refreshed = True
612 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
613 # a github repo. Most of the time this is the same as origin/master.
614 rev = rev or 'origin/HEAD'
615 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
616 if p.returncode != 0:
617 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
618 # Get rid of any uncontrolled files left behind
619 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
620 if p.returncode != 0:
621 raise VCSException("Git clean failed", p.output)
623 def initsubmodules(self):
625 submfile = os.path.join(self.local, '.gitmodules')
626 if not os.path.isfile(submfile):
627 raise VCSException("No git submodules available")
629 # fix submodules not accessible without an account and public key auth
630 with open(submfile, 'r') as f:
631 lines = f.readlines()
632 with open(submfile, 'w') as f:
634 if 'git@github.com' in line:
635 line = line.replace('git@github.com:', 'https://github.com/')
638 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
639 if p.returncode != 0:
640 raise VCSException("Git submodule sync failed", p.output)
641 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
642 if p.returncode != 0:
643 raise VCSException("Git submodule update failed", p.output)
647 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
648 return p.output.splitlines()
650 def latesttags(self, tags, number):
655 ['git', 'show', '--format=format:%ct', '-s', tag],
656 cwd=self.local, output=False)
657 # Timestamp is on the last line. For a normal tag, it's the only
658 # line, but for annotated tags, the rest of the info precedes it.
659 ts = int(p.output.splitlines()[-1])
662 for _, t in sorted(tl)[-number:]:
667 class vcs_gitsvn(vcs):
672 # If the local directory exists, but is somehow not a git repository, git
673 # will traverse up the directory tree until it finds one that is (i.e.
674 # fdroidserver) and then we'll proceed to destory it! This is called as
677 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
678 result = p.output.rstrip()
679 if not result.endswith(self.local):
680 raise VCSException('Repository mismatch')
682 def gotorevisionx(self, rev):
683 if not os.path.exists(self.local):
685 gitsvn_args = ['git', 'svn', 'clone']
686 if ';' in self.remote:
687 remote_split = self.remote.split(';')
688 for i in remote_split[1:]:
689 if i.startswith('trunk='):
690 gitsvn_args.extend(['-T', i[6:]])
691 elif i.startswith('tags='):
692 gitsvn_args.extend(['-t', i[5:]])
693 elif i.startswith('branches='):
694 gitsvn_args.extend(['-b', i[9:]])
695 gitsvn_args.extend([remote_split[0], self.local])
696 p = FDroidPopen(gitsvn_args, output=False)
697 if p.returncode != 0:
698 self.clone_failed = True
699 raise VCSException("Git svn clone failed", p.output)
701 gitsvn_args.extend([self.remote, self.local])
702 p = FDroidPopen(gitsvn_args, output=False)
703 if p.returncode != 0:
704 self.clone_failed = True
705 raise VCSException("Git svn clone failed", p.output)
709 # Discard any working tree changes
710 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
711 if p.returncode != 0:
712 raise VCSException("Git reset failed", p.output)
713 # Remove untracked files now, in case they're tracked in the target
714 # revision (it happens!)
715 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
716 if p.returncode != 0:
717 raise VCSException("Git clean failed", p.output)
718 if not self.refreshed:
719 # Get new commits, branches and tags from repo
720 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
721 if p.returncode != 0:
722 raise VCSException("Git svn fetch failed")
723 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
724 if p.returncode != 0:
725 raise VCSException("Git svn rebase failed", p.output)
726 self.refreshed = True
728 rev = rev or 'master'
730 nospaces_rev = rev.replace(' ', '%20')
731 # Try finding a svn tag
732 for treeish in ['origin/', '']:
733 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
734 if p.returncode == 0:
736 if p.returncode != 0:
737 # No tag found, normal svn rev translation
738 # Translate svn rev into git format
739 rev_split = rev.split('/')
742 for treeish in ['origin/', '']:
743 if len(rev_split) > 1:
744 treeish += rev_split[0]
745 svn_rev = rev_split[1]
748 # if no branch is specified, then assume trunk (i.e. 'master' branch):
752 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
754 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
755 git_rev = p.output.rstrip()
757 if p.returncode == 0 and git_rev:
760 if p.returncode != 0 or not git_rev:
761 # Try a plain git checkout as a last resort
762 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
763 if p.returncode != 0:
764 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
766 # Check out the git rev equivalent to the svn rev
767 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
768 if p.returncode != 0:
769 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
771 # Get rid of any uncontrolled files left behind
772 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
773 if p.returncode != 0:
774 raise VCSException("Git clean failed", p.output)
778 for treeish in ['origin/', '']:
779 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
785 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
786 if p.returncode != 0:
788 return p.output.strip()
796 def gotorevisionx(self, rev):
797 if not os.path.exists(self.local):
798 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
799 if p.returncode != 0:
800 self.clone_failed = True
801 raise VCSException("Hg clone failed", p.output)
803 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
804 if p.returncode != 0:
805 raise VCSException("Hg status failed", p.output)
806 for line in p.output.splitlines():
807 if not line.startswith('? '):
808 raise VCSException("Unexpected output from hg status -uS: " + line)
809 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
810 if not self.refreshed:
811 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
812 if p.returncode != 0:
813 raise VCSException("Hg pull failed", p.output)
814 self.refreshed = True
816 rev = rev or 'default'
819 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
820 if p.returncode != 0:
821 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
822 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
823 # Also delete untracked files, we have to enable purge extension for that:
824 if "'purge' is provided by the following extension" in p.output:
825 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
826 myfile.write("\n[extensions]\nhgext.purge=\n")
827 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
828 if p.returncode != 0:
829 raise VCSException("HG purge failed", p.output)
830 elif p.returncode != 0:
831 raise VCSException("HG purge failed", p.output)
834 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
835 return p.output.splitlines()[1:]
843 def gotorevisionx(self, rev):
844 if not os.path.exists(self.local):
845 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
846 if p.returncode != 0:
847 self.clone_failed = True
848 raise VCSException("Bzr branch failed", p.output)
850 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
851 if p.returncode != 0:
852 raise VCSException("Bzr revert failed", p.output)
853 if not self.refreshed:
854 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
855 if p.returncode != 0:
856 raise VCSException("Bzr update failed", p.output)
857 self.refreshed = True
859 revargs = list(['-r', rev] if rev else [])
860 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
861 if p.returncode != 0:
862 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
865 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
866 return [tag.split(' ')[0].strip() for tag in
867 p.output.splitlines()]
870 def unescape_string(string):
871 if string[0] == '"' and string[-1] == '"':
874 return string.replace("\\'", "'")
877 def retrieve_string(app_dir, string, xmlfiles=None):
882 os.path.join(app_dir, 'res'),
883 os.path.join(app_dir, 'src', 'main', 'res'),
885 for r, d, f in os.walk(res_dir):
886 if os.path.basename(r) == 'values':
887 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
889 if not string.startswith('@string/'):
890 return unescape_string(string)
892 name = string[len('@string/'):]
894 for path in xmlfiles:
895 if not os.path.isfile(path):
897 xml = parse_xml(path)
898 element = xml.find('string[@name="' + name + '"]')
899 if element is not None and element.text is not None:
900 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
905 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
906 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
909 # Return list of existing files that will be used to find the highest vercode
910 def manifest_paths(app_dir, flavours):
912 possible_manifests = \
913 [os.path.join(app_dir, 'AndroidManifest.xml'),
914 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
915 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
916 os.path.join(app_dir, 'build.gradle')]
918 for flavour in flavours:
921 possible_manifests.append(
922 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
924 return [path for path in possible_manifests if os.path.isfile(path)]
927 # Retrieve the package name. Returns the name, or None if not found.
928 def fetch_real_name(app_dir, flavours):
929 for path in manifest_paths(app_dir, flavours):
930 if not has_extension(path, 'xml') or not os.path.isfile(path):
932 logging.debug("fetch_real_name: Checking manifest at " + path)
933 xml = parse_xml(path)
934 app = xml.find('application')
935 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
937 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
938 result = retrieve_string_singleline(app_dir, label)
940 result = result.strip()
945 def get_library_references(root_dir):
947 proppath = os.path.join(root_dir, 'project.properties')
948 if not os.path.isfile(proppath):
950 for line in file(proppath):
951 if not line.startswith('android.library.reference.'):
953 path = line.split('=')[1].strip()
954 relpath = os.path.join(root_dir, path)
955 if not os.path.isdir(relpath):
957 logging.debug("Found subproject at %s" % path)
958 libraries.append(path)
962 def ant_subprojects(root_dir):
963 subprojects = get_library_references(root_dir)
964 for subpath in subprojects:
965 subrelpath = os.path.join(root_dir, subpath)
966 for p in get_library_references(subrelpath):
967 relp = os.path.normpath(os.path.join(subpath, p))
968 if relp not in subprojects:
969 subprojects.insert(0, relp)
973 def remove_debuggable_flags(root_dir):
974 # Remove forced debuggable flags
975 logging.debug("Removing debuggable flags from %s" % root_dir)
976 for root, dirs, files in os.walk(root_dir):
977 if 'AndroidManifest.xml' in files:
978 regsub_file(r'android:debuggable="[^"]*"',
980 os.path.join(root, 'AndroidManifest.xml'))
983 # Extract some information from the AndroidManifest.xml at the given path.
984 # Returns (version, vercode, package), any or all of which might be None.
985 # All values returned are strings.
986 def parse_androidmanifests(paths, ignoreversions=None):
989 return (None, None, None)
991 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
992 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
993 psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
995 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1003 if not os.path.isfile(path):
1006 logging.debug("Parsing manifest at {0}".format(path))
1007 gradle = has_extension(path, 'gradle')
1010 # Remember package name, may be defined separately from version+vercode
1011 package = max_package
1014 for line in file(path):
1016 matches = psearch_g(line)
1018 package = matches.group(1)
1020 matches = vnsearch_g(line)
1022 version = matches.group(2)
1024 matches = vcsearch_g(line)
1026 vercode = matches.group(1)
1028 xml = parse_xml(path)
1029 if "package" in xml.attrib:
1030 package = xml.attrib["package"].encode('utf-8')
1031 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1032 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1033 base_dir = os.path.dirname(path)
1034 version = retrieve_string_singleline(base_dir, version)
1035 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1036 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1037 if string_is_integer(a):
1040 logging.debug("..got package={0}, version={1}, vercode={2}"
1041 .format(package, version, vercode))
1043 # Always grab the package name and version name in case they are not
1044 # together with the highest version code
1045 if max_package is None and package is not None:
1046 max_package = package
1047 if max_version is None and version is not None:
1048 max_version = version
1050 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1051 if not ignoresearch or not ignoresearch(version):
1052 if version is not None:
1053 max_version = version
1054 if vercode is not None:
1055 max_vercode = vercode
1056 if package is not None:
1057 max_package = package
1059 max_version = "Ignore"
1061 if max_version is None:
1062 max_version = "Unknown"
1064 if max_package and not is_valid_package_name(max_package):
1065 raise FDroidException("Invalid package name {0}".format(max_package))
1067 return (max_version, max_vercode, max_package)
1070 def is_valid_package_name(name):
1071 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1074 class FDroidException(Exception):
1076 def __init__(self, value, detail=None):
1078 self.detail = detail
1080 def get_wikitext(self):
1081 ret = repr(self.value) + "\n"
1085 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1093 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1097 class VCSException(FDroidException):
1101 class BuildException(FDroidException):
1105 # Get the specified source library.
1106 # Returns the path to it. Normally this is the path to be used when referencing
1107 # it, which may be a subdirectory of the actual project. If you want the base
1108 # directory of the project, pass 'basepath=True'.
1109 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1110 raw=False, prepare=True, preponly=False, refresh=True):
1118 name, ref = spec.split('@')
1120 number, name = name.split(':', 1)
1122 name, subdir = name.split('/', 1)
1124 if name not in metadata.srclibs:
1125 raise VCSException('srclib ' + name + ' not found.')
1127 srclib = metadata.srclibs[name]
1129 sdir = os.path.join(srclib_dir, name)
1132 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1133 vcs.srclib = (name, number, sdir)
1135 vcs.gotorevision(ref, refresh)
1142 libdir = os.path.join(sdir, subdir)
1143 elif srclib["Subdir"]:
1144 for subdir in srclib["Subdir"]:
1145 libdir_candidate = os.path.join(sdir, subdir)
1146 if os.path.exists(libdir_candidate):
1147 libdir = libdir_candidate
1153 remove_signing_keys(sdir)
1154 remove_debuggable_flags(sdir)
1158 if srclib["Prepare"]:
1159 cmd = replace_config_vars(srclib["Prepare"], None)
1161 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1162 if p.returncode != 0:
1163 raise BuildException("Error running prepare command for srclib %s"
1169 return (name, number, libdir)
1172 # Prepare the source code for a particular build
1173 # 'vcs' - the appropriate vcs object for the application
1174 # 'app' - the application details from the metadata
1175 # 'build' - the build details from the metadata
1176 # 'build_dir' - the path to the build directory, usually
1178 # 'srclib_dir' - the path to the source libraries directory, usually
1180 # 'extlib_dir' - the path to the external libraries directory, usually
1182 # Returns the (root, srclibpaths) where:
1183 # 'root' is the root directory, which may be the same as 'build_dir' or may
1184 # be a subdirectory of it.
1185 # 'srclibpaths' is information on the srclibs being used
1186 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1188 # Optionally, the actual app source can be in a subdirectory
1190 root_dir = os.path.join(build_dir, build['subdir'])
1192 root_dir = build_dir
1194 # Get a working copy of the right revision
1195 logging.info("Getting source for revision " + build['commit'])
1196 vcs.gotorevision(build['commit'], refresh)
1198 # Initialise submodules if required
1199 if build['submodules']:
1200 logging.info("Initialising submodules")
1201 vcs.initsubmodules()
1203 # Check that a subdir (if we're using one) exists. This has to happen
1204 # after the checkout, since it might not exist elsewhere
1205 if not os.path.exists(root_dir):
1206 raise BuildException('Missing subdir ' + root_dir)
1208 # Run an init command if one is required
1210 cmd = replace_config_vars(build['init'], build)
1211 logging.info("Running 'init' commands in %s" % root_dir)
1213 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1214 if p.returncode != 0:
1215 raise BuildException("Error running init command for %s:%s" %
1216 (app['id'], build['version']), p.output)
1218 # Apply patches if any
1220 logging.info("Applying patches")
1221 for patch in build['patch']:
1222 patch = patch.strip()
1223 logging.info("Applying " + patch)
1224 patch_path = os.path.join('metadata', app['id'], patch)
1225 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1226 if p.returncode != 0:
1227 raise BuildException("Failed to apply patch %s" % patch_path)
1229 # Get required source libraries
1231 if build['srclibs']:
1232 logging.info("Collecting source libraries")
1233 for lib in build['srclibs']:
1234 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1236 for name, number, libpath in srclibpaths:
1237 place_srclib(root_dir, int(number) if number else None, libpath)
1239 basesrclib = vcs.getsrclib()
1240 # If one was used for the main source, add that too.
1242 srclibpaths.append(basesrclib)
1244 # Update the local.properties file
1245 localprops = [os.path.join(build_dir, 'local.properties')]
1247 localprops += [os.path.join(root_dir, 'local.properties')]
1248 for path in localprops:
1250 if os.path.isfile(path):
1251 logging.info("Updating local.properties file at %s" % path)
1252 with open(path, 'r') as f:
1256 logging.info("Creating local.properties file at %s" % path)
1257 # Fix old-fashioned 'sdk-location' by copying
1258 # from sdk.dir, if necessary
1259 if build['oldsdkloc']:
1260 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1261 re.S | re.M).group(1)
1262 props += "sdk-location=%s\n" % sdkloc
1264 props += "sdk.dir=%s\n" % config['sdk_path']
1265 props += "sdk-location=%s\n" % config['sdk_path']
1266 if build['ndk_path']:
1268 props += "ndk.dir=%s\n" % build['ndk_path']
1269 props += "ndk-location=%s\n" % build['ndk_path']
1270 # Add java.encoding if necessary
1271 if build['encoding']:
1272 props += "java.encoding=%s\n" % build['encoding']
1273 with open(path, 'w') as f:
1277 if build['type'] == 'gradle':
1278 flavours = build['gradle']
1280 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1281 gradlepluginver = None
1283 gradle_dirs = [root_dir]
1285 # Parent dir build.gradle
1286 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1287 if parent_dir.startswith(build_dir):
1288 gradle_dirs.append(parent_dir)
1290 for dir_path in gradle_dirs:
1293 if not os.path.isdir(dir_path):
1295 for filename in os.listdir(dir_path):
1296 if not filename.endswith('.gradle'):
1298 path = os.path.join(dir_path, filename)
1299 if not os.path.isfile(path):
1301 for line in file(path):
1302 match = version_regex.match(line)
1304 gradlepluginver = match.group(1)
1308 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1310 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1311 build['gradlepluginver'] = LooseVersion('0.11')
1314 n = build["target"].split('-')[1]
1315 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1316 r'compileSdkVersion %s' % n,
1317 os.path.join(root_dir, 'build.gradle'))
1319 # Remove forced debuggable flags
1320 remove_debuggable_flags(root_dir)
1322 # Insert version code and number into the manifest if necessary
1323 if build['forceversion']:
1324 logging.info("Changing the version name")
1325 for path in manifest_paths(root_dir, flavours):
1326 if not os.path.isfile(path):
1328 if has_extension(path, 'xml'):
1329 regsub_file(r'android:versionName="[^"]*"',
1330 r'android:versionName="%s"' % build['version'],
1332 elif has_extension(path, 'gradle'):
1333 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1334 r"""\1versionName '%s'""" % build['version'],
1337 if build['forcevercode']:
1338 logging.info("Changing the version code")
1339 for path in manifest_paths(root_dir, flavours):
1340 if not os.path.isfile(path):
1342 if has_extension(path, 'xml'):
1343 regsub_file(r'android:versionCode="[^"]*"',
1344 r'android:versionCode="%s"' % build['vercode'],
1346 elif has_extension(path, 'gradle'):
1347 regsub_file(r'versionCode[ =]+[0-9]+',
1348 r'versionCode %s' % build['vercode'],
1351 # Delete unwanted files
1353 logging.info("Removing specified files")
1354 for part in getpaths(build_dir, build, 'rm'):
1355 dest = os.path.join(build_dir, part)
1356 logging.info("Removing {0}".format(part))
1357 if os.path.lexists(dest):
1358 if os.path.islink(dest):
1359 FDroidPopen(['unlink', dest], output=False)
1361 FDroidPopen(['rm', '-rf', dest], output=False)
1363 logging.info("...but it didn't exist")
1365 remove_signing_keys(build_dir)
1367 # Add required external libraries
1368 if build['extlibs']:
1369 logging.info("Collecting prebuilt libraries")
1370 libsdir = os.path.join(root_dir, 'libs')
1371 if not os.path.exists(libsdir):
1373 for lib in build['extlibs']:
1375 logging.info("...installing extlib {0}".format(lib))
1376 libf = os.path.basename(lib)
1377 libsrc = os.path.join(extlib_dir, lib)
1378 if not os.path.exists(libsrc):
1379 raise BuildException("Missing extlib file {0}".format(libsrc))
1380 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1382 # Run a pre-build command if one is required
1383 if build['prebuild']:
1384 logging.info("Running 'prebuild' commands in %s" % root_dir)
1386 cmd = replace_config_vars(build['prebuild'], build)
1388 # Substitute source library paths into prebuild commands
1389 for name, number, libpath in srclibpaths:
1390 libpath = os.path.relpath(libpath, root_dir)
1391 cmd = cmd.replace('$$' + name + '$$', libpath)
1393 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1394 if p.returncode != 0:
1395 raise BuildException("Error running prebuild command for %s:%s" %
1396 (app['id'], build['version']), p.output)
1398 # Generate (or update) the ant build file, build.xml...
1399 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1400 parms = ['android', 'update', 'lib-project']
1401 lparms = ['android', 'update', 'project']
1404 parms += ['-t', build['target']]
1405 lparms += ['-t', build['target']]
1406 if build['update'] == ['auto']:
1407 update_dirs = ant_subprojects(root_dir) + ['.']
1409 update_dirs = build['update']
1411 for d in update_dirs:
1412 subdir = os.path.join(root_dir, d)
1414 logging.debug("Updating main project")
1415 cmd = parms + ['-p', d]
1417 logging.debug("Updating subproject %s" % d)
1418 cmd = lparms + ['-p', d]
1419 p = SdkToolsPopen(cmd, cwd=root_dir)
1420 # Check to see whether an error was returned without a proper exit
1421 # code (this is the case for the 'no target set or target invalid'
1423 if p.returncode != 0 or p.output.startswith("Error: "):
1424 raise BuildException("Failed to update project at %s" % d, p.output)
1425 # Clean update dirs via ant
1427 logging.info("Cleaning subproject %s" % d)
1428 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1430 return (root_dir, srclibpaths)
1433 # Split and extend via globbing the paths from a field
1434 def getpaths(build_dir, build, field):
1436 for p in build[field]:
1438 full_path = os.path.join(build_dir, p)
1439 full_path = os.path.normpath(full_path)
1440 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1445 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1451 self.path = os.path.join('stats', 'known_apks.txt')
1453 if os.path.isfile(self.path):
1454 for line in file(self.path):
1455 t = line.rstrip().split(' ')
1457 self.apks[t[0]] = (t[1], None)
1459 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1460 self.changed = False
1462 def writeifchanged(self):
1463 if not self.changed:
1466 if not os.path.exists('stats'):
1470 for apk, app in self.apks.iteritems():
1472 line = apk + ' ' + appid
1474 line += ' ' + time.strftime('%Y-%m-%d', added)
1477 with open(self.path, 'w') as f:
1478 for line in sorted(lst, key=natural_key):
1479 f.write(line + '\n')
1481 # Record an apk (if it's new, otherwise does nothing)
1482 # Returns the date it was added.
1483 def recordapk(self, apk, app):
1484 if apk not in self.apks:
1485 self.apks[apk] = (app, time.gmtime(time.time()))
1487 _, added = self.apks[apk]
1490 # Look up information - given the 'apkname', returns (app id, date added/None).
1491 # Or returns None for an unknown apk.
1492 def getapp(self, apkname):
1493 if apkname in self.apks:
1494 return self.apks[apkname]
1497 # Get the most recent 'num' apps added to the repo, as a list of package ids
1498 # with the most recent first.
1499 def getlatest(self, num):
1501 for apk, app in self.apks.iteritems():
1505 if apps[appid] > added:
1509 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1510 lst = [app for app, _ in sortedapps]
1515 def isApkDebuggable(apkfile, config):
1516 """Returns True if the given apk file is debuggable
1518 :param apkfile: full path to the apk to check"""
1520 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1522 if p.returncode != 0:
1523 logging.critical("Failed to get apk manifest information")
1525 for line in p.output.splitlines():
1526 if 'android:debuggable' in line and not line.endswith('0x0'):
1531 class AsynchronousFileReader(threading.Thread):
1534 Helper class to implement asynchronous reading of a file
1535 in a separate thread. Pushes read lines on a queue to
1536 be consumed in another thread.
1539 def __init__(self, fd, queue):
1540 assert isinstance(queue, Queue.Queue)
1541 assert callable(fd.readline)
1542 threading.Thread.__init__(self)
1547 '''The body of the tread: read lines and put them on the queue.'''
1548 for line in iter(self._fd.readline, ''):
1549 self._queue.put(line)
1552 '''Check whether there is no more content to expect.'''
1553 return not self.is_alive() and self._queue.empty()
1561 def SdkToolsPopen(commands, cwd=None, output=True):
1563 if cmd not in config:
1564 config[cmd] = find_sdk_tools_cmd(commands[0])
1565 return FDroidPopen([config[cmd]] + commands[1:],
1566 cwd=cwd, output=output)
1569 def FDroidPopen(commands, cwd=None, output=True):
1571 Run a command and capture the possibly huge output.
1573 :param commands: command and argument list like in subprocess.Popen
1574 :param cwd: optionally specifies a working directory
1575 :returns: A PopenResult.
1581 cwd = os.path.normpath(cwd)
1582 logging.debug("Directory: %s" % cwd)
1583 logging.debug("> %s" % ' '.join(commands))
1585 result = PopenResult()
1588 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1589 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1591 raise BuildException("OSError while trying to execute " +
1592 ' '.join(commands) + ': ' + str(e))
1594 stdout_queue = Queue.Queue()
1595 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1596 stdout_reader.start()
1598 # Check the queue for output (until there is no more to get)
1599 while not stdout_reader.eof():
1600 while not stdout_queue.empty():
1601 line = stdout_queue.get()
1602 if output and options.verbose:
1603 # Output directly to console
1604 sys.stderr.write(line)
1606 result.output += line
1610 result.returncode = p.wait()
1614 def remove_signing_keys(build_dir):
1615 comment = re.compile(r'[ ]*//')
1616 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1618 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1619 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1620 re.compile(r'.*variant\.outputFile = .*'),
1621 re.compile(r'.*output\.outputFile = .*'),
1622 re.compile(r'.*\.readLine\(.*'),
1624 for root, dirs, files in os.walk(build_dir):
1625 if 'build.gradle' in files:
1626 path = os.path.join(root, 'build.gradle')
1628 with open(path, "r") as o:
1629 lines = o.readlines()
1635 with open(path, "w") as o:
1636 while i < len(lines):
1639 while line.endswith('\\\n'):
1640 line = line.rstrip('\\\n') + lines[i]
1643 if comment.match(line):
1647 opened += line.count('{')
1648 opened -= line.count('}')
1651 if signing_configs.match(line):
1656 if any(s.match(line) for s in line_matches):
1664 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1667 'project.properties',
1669 'default.properties',
1670 'ant.properties', ]:
1671 if propfile in files:
1672 path = os.path.join(root, propfile)
1674 with open(path, "r") as o:
1675 lines = o.readlines()
1679 with open(path, "w") as o:
1681 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1688 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1691 def reset_env_path():
1692 global env, orig_path
1693 env['PATH'] = orig_path
1696 def add_to_env_path(path):
1698 paths = env['PATH'].split(os.pathsep)
1702 env['PATH'] = os.pathsep.join(paths)
1705 def replace_config_vars(cmd, build):
1707 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1708 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1709 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1710 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1711 if build is not None:
1712 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1713 cmd = cmd.replace('$$VERSION$$', build['version'])
1714 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1718 def place_srclib(root_dir, number, libpath):
1721 relpath = os.path.relpath(libpath, root_dir)
1722 proppath = os.path.join(root_dir, 'project.properties')
1725 if os.path.isfile(proppath):
1726 with open(proppath, "r") as o:
1727 lines = o.readlines()
1729 with open(proppath, "w") as o:
1732 if line.startswith('android.library.reference.%d=' % number):
1733 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1738 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1741 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1742 """Verify that two apks are the same
1744 One of the inputs is signed, the other is unsigned. The signature metadata
1745 is transferred from the signed to the unsigned apk, and then jarsigner is
1746 used to verify that the signature from the signed apk is also varlid for
1748 :param signed_apk: Path to a signed apk file
1749 :param unsigned_apk: Path to an unsigned apk file expected to match it
1750 :param tmp_dir: Path to directory for temporary files
1751 :returns: None if the verification is successful, otherwise a string
1752 describing what went wrong.
1754 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1755 with ZipFile(signed_apk) as signed_apk_as_zip:
1756 meta_inf_files = ['META-INF/MANIFEST.MF']
1757 for f in signed_apk_as_zip.namelist():
1758 if sigfile.match(f):
1759 meta_inf_files.append(f)
1760 if len(meta_inf_files) < 3:
1761 return "Signature files missing from {0}".format(signed_apk)
1762 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1763 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1764 for meta_inf_file in meta_inf_files:
1765 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1767 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1768 logging.info("...NOT verified - {0}".format(signed_apk))
1769 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1770 logging.info("...successfully verified")
1774 def compare_apks(apk1, apk2, tmp_dir):
1777 Returns None if the apk content is the same (apart from the signing key),
1778 otherwise a string describing what's different, or what went wrong when
1779 trying to do the comparison.
1782 badchars = re.compile('''[/ :;'"]''')
1783 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1784 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1785 for d in [apk1dir, apk2dir]:
1786 if os.path.exists(d):
1789 os.mkdir(os.path.join(d, 'jar-xf'))
1791 if subprocess.call(['jar', 'xf',
1792 os.path.abspath(apk1)],
1793 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1794 return("Failed to unpack " + apk1)
1795 if subprocess.call(['jar', 'xf',
1796 os.path.abspath(apk2)],
1797 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1798 return("Failed to unpack " + apk2)
1800 # try to find apktool in the path, if it hasn't been manually configed
1801 if 'apktool' not in config:
1802 tmp = find_command('apktool')
1804 config['apktool'] = tmp
1805 if 'apktool' in config:
1806 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1808 return("Failed to unpack " + apk1)
1809 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1811 return("Failed to unpack " + apk2)
1813 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1814 lines = p.output.splitlines()
1815 if len(lines) != 1 or 'META-INF' not in lines[0]:
1816 meld = find_command('meld')
1817 if meld is not None:
1818 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1819 return("Unexpected diff output - " + p.output)
1821 # since everything verifies, delete the comparison to keep cruft down
1822 shutil.rmtree(apk1dir)
1823 shutil.rmtree(apk2dir)
1825 # If we get here, it seems like they're the same!
1829 def find_command(command):
1830 '''find the full path of a command, or None if it can't be found in the PATH'''
1833 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1835 fpath, fname = os.path.split(command)
1840 for path in os.environ["PATH"].split(os.pathsep):
1841 path = path.strip('"')
1842 exe_file = os.path.join(path, command)
1843 if is_exe(exe_file):
1850 '''generate a random password for when generating keys'''
1851 h = hashlib.sha256()
1852 h.update(os.urandom(16)) # salt
1853 h.update(bytes(socket.getfqdn()))
1854 return h.digest().encode('base64').strip()
1857 def genkeystore(localconfig):
1858 '''Generate a new key with random passwords and add it to new keystore'''
1859 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1860 keystoredir = os.path.dirname(localconfig['keystore'])
1861 if keystoredir is None or keystoredir == '':
1862 keystoredir = os.path.join(os.getcwd(), keystoredir)
1863 if not os.path.exists(keystoredir):
1864 os.makedirs(keystoredir, mode=0o700)
1866 write_password_file("keystorepass", localconfig['keystorepass'])
1867 write_password_file("keypass", localconfig['keypass'])
1868 p = FDroidPopen(['keytool', '-genkey',
1869 '-keystore', localconfig['keystore'],
1870 '-alias', localconfig['repo_keyalias'],
1871 '-keyalg', 'RSA', '-keysize', '4096',
1872 '-sigalg', 'SHA256withRSA',
1873 '-validity', '10000',
1874 '-storepass:file', config['keystorepassfile'],
1875 '-keypass:file', config['keypassfile'],
1876 '-dname', localconfig['keydname']])
1877 # TODO keypass should be sent via stdin
1878 if p.returncode != 0:
1879 raise BuildException("Failed to generate key", p.output)
1880 os.chmod(localconfig['keystore'], 0o0600)
1881 # now show the lovely key that was just generated
1882 p = FDroidPopen(['keytool', '-list', '-v',
1883 '-keystore', localconfig['keystore'],
1884 '-alias', localconfig['repo_keyalias'],
1885 '-storepass:file', config['keystorepassfile']])
1886 logging.info(p.output.strip() + '\n\n')
1889 def write_to_config(thisconfig, key, value=None):
1890 '''write a key/value to the local config.py'''
1892 origkey = key + '_orig'
1893 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1894 with open('config.py', 'r') as f:
1896 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1897 repl = '\n' + key + ' = "' + value + '"'
1898 data = re.sub(pattern, repl, data)
1899 # if this key is not in the file, append it
1900 if not re.match('\s*' + key + '\s*=\s*"', data):
1902 # make sure the file ends with a carraige return
1903 if not re.match('\n$', data):
1905 with open('config.py', 'w') as f:
1909 def parse_xml(path):
1910 return XMLElementTree.parse(path).getroot()
1913 def string_is_integer(string):
1921 def get_per_app_repos():
1922 '''per-app repos are dirs named with the packageName of a single app'''
1924 # Android packageNames are Java packages, they may contain uppercase or
1925 # lowercase letters ('A' through 'Z'), numbers, and underscores
1926 # ('_'). However, individual package name parts may only start with
1927 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1928 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1931 for root, dirs, files in os.walk(os.getcwd()):
1933 print 'checking', root, 'for', d
1934 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1935 # standard parts of an fdroid repo, so never packageNames
1938 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):