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.1",
62 'accepted_formats': ['txt', 'yaml'],
63 'sync_from_local_copy_dir': False,
64 'per_app_repos': False,
65 'make_current_version_link': True,
66 'current_version_name_source': 'Name',
67 'update_stats': False,
71 'stats_to_carbon': False,
73 'build_server_always': False,
74 'keystore': 'keystore.jks',
75 'smartcardoptions': [],
81 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
82 'repo_name': "My First FDroid Repo Demo",
83 'repo_icon': "fdroid-icon.png",
84 'repo_description': '''
85 This is a repository of apps to be used with FDroid. Applications in this
86 repository are either official binaries built by the original application
87 developers, or are binaries built from source by the admin of f-droid.org
88 using the tools on https://gitlab.com/u/fdroid.
94 def fill_config_defaults(thisconfig):
95 for k, v in default_config.items():
96 if k not in thisconfig:
99 # Expand paths (~users and $vars)
100 def expand_path(path):
104 path = os.path.expanduser(path)
105 path = os.path.expandvars(path)
110 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
115 thisconfig[k + '_orig'] = v
117 for k in ['ndk_paths']:
123 thisconfig[k][k2] = exp
124 thisconfig[k][k2 + '_orig'] = v
127 def regsub_file(pattern, repl, path):
128 with open(path, 'r') as f:
130 text = re.sub(pattern, repl, text)
131 with open(path, 'w') as f:
135 def read_config(opts, config_file='config.py'):
136 """Read the repository config
138 The config is read from config_file, which is in the current directory when
139 any of the repo management commands are used.
141 global config, options, env, orig_path
143 if config is not None:
145 if not os.path.isfile(config_file):
146 logging.critical("Missing config file - is this a repo directory?")
153 logging.debug("Reading %s" % config_file)
154 execfile(config_file, config)
156 # smartcardoptions must be a list since its command line args for Popen
157 if 'smartcardoptions' in config:
158 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
159 elif 'keystore' in config and config['keystore'] == 'NONE':
160 # keystore='NONE' means use smartcard, these are required defaults
161 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
162 'SunPKCS11-OpenSC', '-providerClass',
163 'sun.security.pkcs11.SunPKCS11',
164 '-providerArg', 'opensc-fdroid.cfg']
166 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
167 st = os.stat(config_file)
168 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
169 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
171 fill_config_defaults(config)
173 # There is no standard, so just set up the most common environment
176 orig_path = env['PATH']
177 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
178 env[n] = config['sdk_path']
180 for k in ["keystorepass", "keypass"]:
182 write_password_file(k)
184 for k in ["repo_description", "archive_description"]:
186 config[k] = clean_description(config[k])
188 if 'serverwebroot' in config:
189 if isinstance(config['serverwebroot'], basestring):
190 roots = [config['serverwebroot']]
191 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
192 roots = config['serverwebroot']
194 raise TypeError('only accepts strings, lists, and tuples')
196 for rootstr in roots:
197 # since this is used with rsync, where trailing slashes have
198 # meaning, ensure there is always a trailing slash
199 if rootstr[-1] != '/':
201 rootlist.append(rootstr.replace('//', '/'))
202 config['serverwebroot'] = rootlist
207 def get_ndk_path(version):
209 version = 'r10e' # falls back to latest
210 paths = config['ndk_paths']
211 if version not in paths:
213 return paths[version] or ''
216 def find_sdk_tools_cmd(cmd):
217 '''find a working path to a tool from the Android SDK'''
220 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
221 # try to find a working path to this command, in all the recent possible paths
222 if 'build_tools' in config:
223 build_tools = os.path.join(config['sdk_path'], 'build-tools')
224 # if 'build_tools' was manually set and exists, check only that one
225 configed_build_tools = os.path.join(build_tools, config['build_tools'])
226 if os.path.exists(configed_build_tools):
227 tooldirs.append(configed_build_tools)
229 # no configed version, so hunt known paths for it
230 for f in sorted(os.listdir(build_tools), reverse=True):
231 if os.path.isdir(os.path.join(build_tools, f)):
232 tooldirs.append(os.path.join(build_tools, f))
233 tooldirs.append(build_tools)
234 sdk_tools = os.path.join(config['sdk_path'], 'tools')
235 if os.path.exists(sdk_tools):
236 tooldirs.append(sdk_tools)
237 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
238 if os.path.exists(sdk_platform_tools):
239 tooldirs.append(sdk_platform_tools)
240 tooldirs.append('/usr/bin')
242 if os.path.isfile(os.path.join(d, cmd)):
243 return os.path.join(d, cmd)
244 # did not find the command, exit with error message
245 ensure_build_tools_exists(config)
248 def test_sdk_exists(thisconfig):
249 if 'sdk_path' not in thisconfig:
250 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
253 logging.error("'sdk_path' not set in config.py!")
255 if thisconfig['sdk_path'] == default_config['sdk_path']:
256 logging.error('No Android SDK found!')
257 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
258 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
260 if not os.path.exists(thisconfig['sdk_path']):
261 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
263 if not os.path.isdir(thisconfig['sdk_path']):
264 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
266 for d in ['build-tools', 'platform-tools', 'tools']:
267 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
268 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
269 thisconfig['sdk_path'], d))
274 def ensure_build_tools_exists(thisconfig):
275 if not test_sdk_exists(thisconfig):
277 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
278 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
279 if not os.path.isdir(versioned_build_tools):
280 logging.critical('Android Build Tools path "'
281 + versioned_build_tools + '" does not exist!')
285 def write_password_file(pwtype, password=None):
287 writes out passwords to a protected file instead of passing passwords as
288 command line argments
290 filename = '.fdroid.' + pwtype + '.txt'
291 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
293 os.write(fd, config[pwtype])
295 os.write(fd, password)
297 config[pwtype + 'file'] = filename
300 # Given the arguments in the form of multiple appid:[vc] strings, this returns
301 # a dictionary with the set of vercodes specified for each package.
302 def read_pkg_args(args, allow_vercodes=False):
309 if allow_vercodes and ':' in p:
310 package, vercode = p.split(':')
312 package, vercode = p, None
313 if package not in vercodes:
314 vercodes[package] = [vercode] if vercode else []
316 elif vercode and vercode not in vercodes[package]:
317 vercodes[package] += [vercode] if vercode else []
322 # On top of what read_pkg_args does, this returns the whole app metadata, but
323 # limiting the builds list to the builds matching the vercodes specified.
324 def read_app_args(args, allapps, allow_vercodes=False):
326 vercodes = read_pkg_args(args, allow_vercodes)
332 for appid, app in allapps.iteritems():
333 if appid in vercodes:
336 if len(apps) != len(vercodes):
339 logging.critical("No such package: %s" % p)
340 raise FDroidException("Found invalid app ids in arguments")
342 raise FDroidException("No packages specified")
345 for appid, app in apps.iteritems():
349 app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
350 if len(app['builds']) != len(vercodes[appid]):
352 allvcs = [b['vercode'] for b in app['builds']]
353 for v in vercodes[appid]:
355 logging.critical("No such vercode %s for app %s" % (v, appid))
358 raise FDroidException("Found invalid vercodes for some apps")
363 def has_extension(filename, extension):
364 name, ext = os.path.splitext(filename)
365 ext = ext.lower()[1:]
366 return ext == extension
371 def clean_description(description):
372 'Remove unneeded newlines and spaces from a block of description text'
374 # this is split up by paragraph to make removing the newlines easier
375 for paragraph in re.split(r'\n\n', description):
376 paragraph = re.sub('\r', '', paragraph)
377 paragraph = re.sub('\n', ' ', paragraph)
378 paragraph = re.sub(' {2,}', ' ', paragraph)
379 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
380 returnstring += paragraph + '\n\n'
381 return returnstring.rstrip('\n')
384 def apknameinfo(filename):
386 filename = os.path.basename(filename)
387 if apk_regex is None:
388 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
389 m = apk_regex.match(filename)
391 result = (m.group(1), m.group(2))
392 except AttributeError:
393 raise FDroidException("Invalid apk name: %s" % filename)
397 def getapkname(app, build):
398 return "%s_%s.apk" % (app['id'], build['vercode'])
401 def getsrcname(app, build):
402 return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
409 return app['Auto Name']
414 return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
417 def getvcs(vcstype, remote, local):
419 return vcs_git(remote, local)
420 if vcstype == 'git-svn':
421 return vcs_gitsvn(remote, local)
423 return vcs_hg(remote, local)
425 return vcs_bzr(remote, local)
426 if vcstype == 'srclib':
427 if local != os.path.join('build', 'srclib', remote):
428 raise VCSException("Error: srclib paths are hard-coded!")
429 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
431 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
432 raise VCSException("Invalid vcs type " + vcstype)
435 def getsrclibvcs(name):
436 if name not in metadata.srclibs:
437 raise VCSException("Missing srclib " + name)
438 return metadata.srclibs[name]['Repo Type']
443 def __init__(self, remote, local):
445 # svn, git-svn and bzr may require auth
447 if self.repotype() in ('git-svn', 'bzr'):
449 if self.repotype == 'git-svn':
450 raise VCSException("Authentication is not supported for git-svn")
451 self.username, remote = remote.split('@')
452 if ':' not in self.username:
453 raise VCSException("Password required with username")
454 self.username, self.password = self.username.split(':')
458 self.clone_failed = False
459 self.refreshed = False
465 # Take the local repository to a clean version of the given revision, which
466 # is specificed in the VCS's native format. Beforehand, the repository can
467 # be dirty, or even non-existent. If the repository does already exist
468 # locally, it will be updated from the origin, but only once in the
469 # lifetime of the vcs object.
470 # None is acceptable for 'rev' if you know you are cloning a clean copy of
471 # the repo - otherwise it must specify a valid revision.
472 def gotorevision(self, rev, refresh=True):
474 if self.clone_failed:
475 raise VCSException("Downloading the repository already failed once, not trying again.")
477 # The .fdroidvcs-id file for a repo tells us what VCS type
478 # and remote that directory was created from, allowing us to drop it
479 # automatically if either of those things changes.
480 fdpath = os.path.join(self.local, '..',
481 '.fdroidvcs-' + os.path.basename(self.local))
482 cdata = self.repotype() + ' ' + self.remote
485 if os.path.exists(self.local):
486 if os.path.exists(fdpath):
487 with open(fdpath, 'r') as f:
488 fsdata = f.read().strip()
493 logging.info("Repository details for %s changed - deleting" % (
497 logging.info("Repository details for %s missing - deleting" % (
500 shutil.rmtree(self.local)
504 self.refreshed = True
507 self.gotorevisionx(rev)
508 except FDroidException, e:
511 # If necessary, write the .fdroidvcs file.
512 if writeback and not self.clone_failed:
513 with open(fdpath, 'w') as f:
519 # Derived classes need to implement this. It's called once basic checking
520 # has been performend.
521 def gotorevisionx(self, rev):
522 raise VCSException("This VCS type doesn't define gotorevisionx")
524 # Initialise and update submodules
525 def initsubmodules(self):
526 raise VCSException('Submodules not supported for this vcs type')
528 # Get a list of all known tags
530 if not self._gettags:
531 raise VCSException('gettags not supported for this vcs type')
533 for tag in self._gettags():
534 if re.match('[-A-Za-z0-9_. /]+$', tag):
538 def latesttags(self, tags, number):
539 """Get the most recent tags in a given list.
541 :param tags: a list of tags
542 :param number: the number to return
543 :returns: A list containing the most recent tags in the provided
544 list, up to the maximum number given.
546 raise VCSException('latesttags not supported for this vcs type')
548 # Get current commit reference (hash, revision, etc)
550 raise VCSException('getref not supported for this vcs type')
552 # Returns the srclib (name, path) used in setting up the current
563 # If the local directory exists, but is somehow not a git repository, git
564 # will traverse up the directory tree until it finds one that is (i.e.
565 # fdroidserver) and then we'll proceed to destroy it! This is called as
568 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
569 result = p.output.rstrip()
570 if not result.endswith(self.local):
571 raise VCSException('Repository mismatch')
573 def gotorevisionx(self, rev):
574 if not os.path.exists(self.local):
576 p = FDroidPopen(['git', 'clone', self.remote, self.local])
577 if p.returncode != 0:
578 self.clone_failed = True
579 raise VCSException("Git clone failed", p.output)
583 # Discard any working tree changes
584 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
585 'git', 'reset', '--hard'], cwd=self.local, output=False)
586 if p.returncode != 0:
587 raise VCSException("Git reset failed", p.output)
588 # Remove untracked files now, in case they're tracked in the target
589 # revision (it happens!)
590 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
591 'git', 'clean', '-dffx'], cwd=self.local, output=False)
592 if p.returncode != 0:
593 raise VCSException("Git clean failed", p.output)
594 if not self.refreshed:
595 # Get latest commits and tags from remote
596 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
597 if p.returncode != 0:
598 raise VCSException("Git fetch failed", p.output)
599 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
600 if p.returncode != 0:
601 raise VCSException("Git fetch failed", p.output)
602 # Recreate origin/HEAD as git clone would do it, in case it disappeared
603 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
604 if p.returncode != 0:
605 lines = p.output.splitlines()
606 if 'Multiple remote HEAD branches' not in lines[0]:
607 raise VCSException("Git remote set-head failed", p.output)
608 branch = lines[1].split(' ')[-1]
609 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
610 if p2.returncode != 0:
611 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
612 self.refreshed = True
613 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
614 # a github repo. Most of the time this is the same as origin/master.
615 rev = rev or 'origin/HEAD'
616 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
617 if p.returncode != 0:
618 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
619 # Get rid of any uncontrolled files left behind
620 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
621 if p.returncode != 0:
622 raise VCSException("Git clean failed", p.output)
624 def initsubmodules(self):
626 submfile = os.path.join(self.local, '.gitmodules')
627 if not os.path.isfile(submfile):
628 raise VCSException("No git submodules available")
630 # fix submodules not accessible without an account and public key auth
631 with open(submfile, 'r') as f:
632 lines = f.readlines()
633 with open(submfile, 'w') as f:
635 if 'git@github.com' in line:
636 line = line.replace('git@github.com:', 'https://github.com/')
639 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
640 if p.returncode != 0:
641 raise VCSException("Git submodule sync failed", p.output)
642 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
643 if p.returncode != 0:
644 raise VCSException("Git submodule update failed", p.output)
648 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
649 return p.output.splitlines()
651 def latesttags(self, tags, number):
656 ['git', 'show', '--format=format:%ct', '-s', tag],
657 cwd=self.local, output=False)
658 # Timestamp is on the last line. For a normal tag, it's the only
659 # line, but for annotated tags, the rest of the info precedes it.
660 ts = int(p.output.splitlines()[-1])
663 for _, t in sorted(tl)[-number:]:
668 class vcs_gitsvn(vcs):
673 # If the local directory exists, but is somehow not a git repository, git
674 # will traverse up the directory tree until it finds one that is (i.e.
675 # fdroidserver) and then we'll proceed to destory it! This is called as
678 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
679 result = p.output.rstrip()
680 if not result.endswith(self.local):
681 raise VCSException('Repository mismatch')
683 def gotorevisionx(self, rev):
684 if not os.path.exists(self.local):
686 gitsvn_args = ['git', 'svn', 'clone']
687 if ';' in self.remote:
688 remote_split = self.remote.split(';')
689 for i in remote_split[1:]:
690 if i.startswith('trunk='):
691 gitsvn_args.extend(['-T', i[6:]])
692 elif i.startswith('tags='):
693 gitsvn_args.extend(['-t', i[5:]])
694 elif i.startswith('branches='):
695 gitsvn_args.extend(['-b', i[9:]])
696 gitsvn_args.extend([remote_split[0], self.local])
697 p = FDroidPopen(gitsvn_args, output=False)
698 if p.returncode != 0:
699 self.clone_failed = True
700 raise VCSException("Git svn clone failed", p.output)
702 gitsvn_args.extend([self.remote, self.local])
703 p = FDroidPopen(gitsvn_args, output=False)
704 if p.returncode != 0:
705 self.clone_failed = True
706 raise VCSException("Git svn clone failed", p.output)
710 # Discard any working tree changes
711 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
712 if p.returncode != 0:
713 raise VCSException("Git reset failed", p.output)
714 # Remove untracked files now, in case they're tracked in the target
715 # revision (it happens!)
716 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
717 if p.returncode != 0:
718 raise VCSException("Git clean failed", p.output)
719 if not self.refreshed:
720 # Get new commits, branches and tags from repo
721 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
722 if p.returncode != 0:
723 raise VCSException("Git svn fetch failed")
724 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
725 if p.returncode != 0:
726 raise VCSException("Git svn rebase failed", p.output)
727 self.refreshed = True
729 rev = rev or 'master'
731 nospaces_rev = rev.replace(' ', '%20')
732 # Try finding a svn tag
733 for treeish in ['origin/', '']:
734 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
735 if p.returncode == 0:
737 if p.returncode != 0:
738 # No tag found, normal svn rev translation
739 # Translate svn rev into git format
740 rev_split = rev.split('/')
743 for treeish in ['origin/', '']:
744 if len(rev_split) > 1:
745 treeish += rev_split[0]
746 svn_rev = rev_split[1]
749 # if no branch is specified, then assume trunk (i.e. 'master' branch):
753 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
755 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
756 git_rev = p.output.rstrip()
758 if p.returncode == 0 and git_rev:
761 if p.returncode != 0 or not git_rev:
762 # Try a plain git checkout as a last resort
763 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
764 if p.returncode != 0:
765 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
767 # Check out the git rev equivalent to the svn rev
768 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
769 if p.returncode != 0:
770 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
772 # Get rid of any uncontrolled files left behind
773 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
774 if p.returncode != 0:
775 raise VCSException("Git clean failed", p.output)
779 for treeish in ['origin/', '']:
780 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
786 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
787 if p.returncode != 0:
789 return p.output.strip()
797 def gotorevisionx(self, rev):
798 if not os.path.exists(self.local):
799 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
800 if p.returncode != 0:
801 self.clone_failed = True
802 raise VCSException("Hg clone failed", p.output)
804 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
805 if p.returncode != 0:
806 raise VCSException("Hg status failed", p.output)
807 for line in p.output.splitlines():
808 if not line.startswith('? '):
809 raise VCSException("Unexpected output from hg status -uS: " + line)
810 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
811 if not self.refreshed:
812 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
813 if p.returncode != 0:
814 raise VCSException("Hg pull failed", p.output)
815 self.refreshed = True
817 rev = rev or 'default'
820 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
821 if p.returncode != 0:
822 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
823 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
824 # Also delete untracked files, we have to enable purge extension for that:
825 if "'purge' is provided by the following extension" in p.output:
826 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
827 myfile.write("\n[extensions]\nhgext.purge=\n")
828 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
829 if p.returncode != 0:
830 raise VCSException("HG purge failed", p.output)
831 elif p.returncode != 0:
832 raise VCSException("HG purge failed", p.output)
835 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
836 return p.output.splitlines()[1:]
844 def gotorevisionx(self, rev):
845 if not os.path.exists(self.local):
846 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
847 if p.returncode != 0:
848 self.clone_failed = True
849 raise VCSException("Bzr branch failed", p.output)
851 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
852 if p.returncode != 0:
853 raise VCSException("Bzr revert failed", p.output)
854 if not self.refreshed:
855 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
856 if p.returncode != 0:
857 raise VCSException("Bzr update failed", p.output)
858 self.refreshed = True
860 revargs = list(['-r', rev] if rev else [])
861 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
862 if p.returncode != 0:
863 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
866 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
867 return [tag.split(' ')[0].strip() for tag in
868 p.output.splitlines()]
871 def unescape_string(string):
872 if string[0] == '"' and string[-1] == '"':
875 return string.replace("\\'", "'")
878 def retrieve_string(app_dir, string, xmlfiles=None):
883 os.path.join(app_dir, 'res'),
884 os.path.join(app_dir, 'src', 'main', 'res'),
886 for r, d, f in os.walk(res_dir):
887 if os.path.basename(r) == 'values':
888 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
890 if not string.startswith('@string/'):
891 return unescape_string(string)
893 name = string[len('@string/'):]
895 for path in xmlfiles:
896 if not os.path.isfile(path):
898 xml = parse_xml(path)
899 element = xml.find('string[@name="' + name + '"]')
900 if element is not None and element.text is not None:
901 return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
906 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
907 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
910 # Return list of existing files that will be used to find the highest vercode
911 def manifest_paths(app_dir, flavours):
913 possible_manifests = \
914 [os.path.join(app_dir, 'AndroidManifest.xml'),
915 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
916 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
917 os.path.join(app_dir, 'build.gradle')]
919 for flavour in flavours:
922 possible_manifests.append(
923 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
925 return [path for path in possible_manifests if os.path.isfile(path)]
928 # Retrieve the package name. Returns the name, or None if not found.
929 def fetch_real_name(app_dir, flavours):
930 for path in manifest_paths(app_dir, flavours):
931 if not has_extension(path, 'xml') or not os.path.isfile(path):
933 logging.debug("fetch_real_name: Checking manifest at " + path)
934 xml = parse_xml(path)
935 app = xml.find('application')
936 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
938 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
939 result = retrieve_string_singleline(app_dir, label)
941 result = result.strip()
946 def get_library_references(root_dir):
948 proppath = os.path.join(root_dir, 'project.properties')
949 if not os.path.isfile(proppath):
951 for line in file(proppath):
952 if not line.startswith('android.library.reference.'):
954 path = line.split('=')[1].strip()
955 relpath = os.path.join(root_dir, path)
956 if not os.path.isdir(relpath):
958 logging.debug("Found subproject at %s" % path)
959 libraries.append(path)
963 def ant_subprojects(root_dir):
964 subprojects = get_library_references(root_dir)
965 for subpath in subprojects:
966 subrelpath = os.path.join(root_dir, subpath)
967 for p in get_library_references(subrelpath):
968 relp = os.path.normpath(os.path.join(subpath, p))
969 if relp not in subprojects:
970 subprojects.insert(0, relp)
974 def remove_debuggable_flags(root_dir):
975 # Remove forced debuggable flags
976 logging.debug("Removing debuggable flags from %s" % root_dir)
977 for root, dirs, files in os.walk(root_dir):
978 if 'AndroidManifest.xml' in files:
979 regsub_file(r'android:debuggable="[^"]*"',
981 os.path.join(root, 'AndroidManifest.xml'))
984 # Extract some information from the AndroidManifest.xml at the given path.
985 # Returns (version, vercode, package), any or all of which might be None.
986 # All values returned are strings.
987 def parse_androidmanifests(paths, ignoreversions=None):
990 return (None, None, None)
992 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
993 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
994 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
996 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1004 if not os.path.isfile(path):
1007 logging.debug("Parsing manifest at {0}".format(path))
1008 gradle = has_extension(path, 'gradle')
1014 for line in file(path):
1015 # Grab first occurence of each to avoid running into
1016 # alternative flavours and builds.
1018 matches = psearch_g(line)
1020 package = matches.group(2)
1022 matches = vnsearch_g(line)
1024 version = matches.group(2)
1026 matches = vcsearch_g(line)
1028 vercode = matches.group(1)
1030 xml = parse_xml(path)
1031 if "package" in xml.attrib:
1032 package = xml.attrib["package"].encode('utf-8')
1033 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1034 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1035 base_dir = os.path.dirname(path)
1036 version = retrieve_string_singleline(base_dir, version)
1037 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1038 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1039 if string_is_integer(a):
1042 # Remember package name, may be defined separately from version+vercode
1044 package = max_package
1046 logging.debug("..got package={0}, version={1}, vercode={2}"
1047 .format(package, version, vercode))
1049 # Always grab the package name and version name in case they are not
1050 # together with the highest version code
1051 if max_package is None and package is not None:
1052 max_package = package
1053 if max_version is None and version is not None:
1054 max_version = version
1056 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1057 if not ignoresearch or not ignoresearch(version):
1058 if version is not None:
1059 max_version = version
1060 if vercode is not None:
1061 max_vercode = vercode
1062 if package is not None:
1063 max_package = package
1065 max_version = "Ignore"
1067 if max_version is None:
1068 max_version = "Unknown"
1070 if max_package and not is_valid_package_name(max_package):
1071 raise FDroidException("Invalid package name {0}".format(max_package))
1073 return (max_version, max_vercode, max_package)
1076 def is_valid_package_name(name):
1077 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1080 class FDroidException(Exception):
1082 def __init__(self, value, detail=None):
1084 self.detail = detail
1086 def get_wikitext(self):
1087 ret = repr(self.value) + "\n"
1091 txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1099 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1103 class VCSException(FDroidException):
1107 class BuildException(FDroidException):
1111 # Get the specified source library.
1112 # Returns the path to it. Normally this is the path to be used when referencing
1113 # it, which may be a subdirectory of the actual project. If you want the base
1114 # directory of the project, pass 'basepath=True'.
1115 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1116 raw=False, prepare=True, preponly=False, refresh=True):
1124 name, ref = spec.split('@')
1126 number, name = name.split(':', 1)
1128 name, subdir = name.split('/', 1)
1130 if name not in metadata.srclibs:
1131 raise VCSException('srclib ' + name + ' not found.')
1133 srclib = metadata.srclibs[name]
1135 sdir = os.path.join(srclib_dir, name)
1138 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1139 vcs.srclib = (name, number, sdir)
1141 vcs.gotorevision(ref, refresh)
1148 libdir = os.path.join(sdir, subdir)
1149 elif srclib["Subdir"]:
1150 for subdir in srclib["Subdir"]:
1151 libdir_candidate = os.path.join(sdir, subdir)
1152 if os.path.exists(libdir_candidate):
1153 libdir = libdir_candidate
1159 remove_signing_keys(sdir)
1160 remove_debuggable_flags(sdir)
1164 if srclib["Prepare"]:
1165 cmd = replace_config_vars(srclib["Prepare"], None)
1167 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1168 if p.returncode != 0:
1169 raise BuildException("Error running prepare command for srclib %s"
1175 return (name, number, libdir)
1178 # Prepare the source code for a particular build
1179 # 'vcs' - the appropriate vcs object for the application
1180 # 'app' - the application details from the metadata
1181 # 'build' - the build details from the metadata
1182 # 'build_dir' - the path to the build directory, usually
1184 # 'srclib_dir' - the path to the source libraries directory, usually
1186 # 'extlib_dir' - the path to the external libraries directory, usually
1188 # Returns the (root, srclibpaths) where:
1189 # 'root' is the root directory, which may be the same as 'build_dir' or may
1190 # be a subdirectory of it.
1191 # 'srclibpaths' is information on the srclibs being used
1192 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1194 # Optionally, the actual app source can be in a subdirectory
1196 root_dir = os.path.join(build_dir, build['subdir'])
1198 root_dir = build_dir
1200 # Get a working copy of the right revision
1201 logging.info("Getting source for revision " + build['commit'])
1202 vcs.gotorevision(build['commit'], refresh)
1204 # Initialise submodules if required
1205 if build['submodules']:
1206 logging.info("Initialising submodules")
1207 vcs.initsubmodules()
1209 # Check that a subdir (if we're using one) exists. This has to happen
1210 # after the checkout, since it might not exist elsewhere
1211 if not os.path.exists(root_dir):
1212 raise BuildException('Missing subdir ' + root_dir)
1214 # Run an init command if one is required
1216 cmd = replace_config_vars(build['init'], build)
1217 logging.info("Running 'init' commands in %s" % root_dir)
1219 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1220 if p.returncode != 0:
1221 raise BuildException("Error running init command for %s:%s" %
1222 (app['id'], build['version']), p.output)
1224 # Apply patches if any
1226 logging.info("Applying patches")
1227 for patch in build['patch']:
1228 patch = patch.strip()
1229 logging.info("Applying " + patch)
1230 patch_path = os.path.join('metadata', app['id'], patch)
1231 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1232 if p.returncode != 0:
1233 raise BuildException("Failed to apply patch %s" % patch_path)
1235 # Get required source libraries
1237 if build['srclibs']:
1238 logging.info("Collecting source libraries")
1239 for lib in build['srclibs']:
1240 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1242 for name, number, libpath in srclibpaths:
1243 place_srclib(root_dir, int(number) if number else None, libpath)
1245 basesrclib = vcs.getsrclib()
1246 # If one was used for the main source, add that too.
1248 srclibpaths.append(basesrclib)
1250 # Update the local.properties file
1251 localprops = [os.path.join(build_dir, 'local.properties')]
1253 localprops += [os.path.join(root_dir, 'local.properties')]
1254 for path in localprops:
1256 if os.path.isfile(path):
1257 logging.info("Updating local.properties file at %s" % path)
1258 with open(path, 'r') as f:
1262 logging.info("Creating local.properties file at %s" % path)
1263 # Fix old-fashioned 'sdk-location' by copying
1264 # from sdk.dir, if necessary
1265 if build['oldsdkloc']:
1266 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1267 re.S | re.M).group(1)
1268 props += "sdk-location=%s\n" % sdkloc
1270 props += "sdk.dir=%s\n" % config['sdk_path']
1271 props += "sdk-location=%s\n" % config['sdk_path']
1272 if build['ndk_path']:
1274 props += "ndk.dir=%s\n" % build['ndk_path']
1275 props += "ndk-location=%s\n" % build['ndk_path']
1276 # Add java.encoding if necessary
1277 if build['encoding']:
1278 props += "java.encoding=%s\n" % build['encoding']
1279 with open(path, 'w') as f:
1283 if build['type'] == 'gradle':
1284 flavours = build['gradle']
1286 version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1287 gradlepluginver = None
1289 gradle_dirs = [root_dir]
1291 # Parent dir build.gradle
1292 parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1293 if parent_dir.startswith(build_dir):
1294 gradle_dirs.append(parent_dir)
1296 for dir_path in gradle_dirs:
1299 if not os.path.isdir(dir_path):
1301 for filename in os.listdir(dir_path):
1302 if not filename.endswith('.gradle'):
1304 path = os.path.join(dir_path, filename)
1305 if not os.path.isfile(path):
1307 for line in file(path):
1308 match = version_regex.match(line)
1310 gradlepluginver = match.group(1)
1314 build['gradlepluginver'] = LooseVersion(gradlepluginver)
1316 logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1317 build['gradlepluginver'] = LooseVersion('0.11')
1320 n = build["target"].split('-')[1]
1321 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1322 r'compileSdkVersion %s' % n,
1323 os.path.join(root_dir, 'build.gradle'))
1325 # Remove forced debuggable flags
1326 remove_debuggable_flags(root_dir)
1328 # Insert version code and number into the manifest if necessary
1329 if build['forceversion']:
1330 logging.info("Changing the version name")
1331 for path in manifest_paths(root_dir, flavours):
1332 if not os.path.isfile(path):
1334 if has_extension(path, 'xml'):
1335 regsub_file(r'android:versionName="[^"]*"',
1336 r'android:versionName="%s"' % build['version'],
1338 elif has_extension(path, 'gradle'):
1339 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1340 r"""\1versionName '%s'""" % build['version'],
1343 if build['forcevercode']:
1344 logging.info("Changing the version code")
1345 for path in manifest_paths(root_dir, flavours):
1346 if not os.path.isfile(path):
1348 if has_extension(path, 'xml'):
1349 regsub_file(r'android:versionCode="[^"]*"',
1350 r'android:versionCode="%s"' % build['vercode'],
1352 elif has_extension(path, 'gradle'):
1353 regsub_file(r'versionCode[ =]+[0-9]+',
1354 r'versionCode %s' % build['vercode'],
1357 # Delete unwanted files
1359 logging.info("Removing specified files")
1360 for part in getpaths(build_dir, build, 'rm'):
1361 dest = os.path.join(build_dir, part)
1362 logging.info("Removing {0}".format(part))
1363 if os.path.lexists(dest):
1364 if os.path.islink(dest):
1365 FDroidPopen(['unlink', dest], output=False)
1367 FDroidPopen(['rm', '-rf', dest], output=False)
1369 logging.info("...but it didn't exist")
1371 remove_signing_keys(build_dir)
1373 # Add required external libraries
1374 if build['extlibs']:
1375 logging.info("Collecting prebuilt libraries")
1376 libsdir = os.path.join(root_dir, 'libs')
1377 if not os.path.exists(libsdir):
1379 for lib in build['extlibs']:
1381 logging.info("...installing extlib {0}".format(lib))
1382 libf = os.path.basename(lib)
1383 libsrc = os.path.join(extlib_dir, lib)
1384 if not os.path.exists(libsrc):
1385 raise BuildException("Missing extlib file {0}".format(libsrc))
1386 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1388 # Run a pre-build command if one is required
1389 if build['prebuild']:
1390 logging.info("Running 'prebuild' commands in %s" % root_dir)
1392 cmd = replace_config_vars(build['prebuild'], build)
1394 # Substitute source library paths into prebuild commands
1395 for name, number, libpath in srclibpaths:
1396 libpath = os.path.relpath(libpath, root_dir)
1397 cmd = cmd.replace('$$' + name + '$$', libpath)
1399 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1400 if p.returncode != 0:
1401 raise BuildException("Error running prebuild command for %s:%s" %
1402 (app['id'], build['version']), p.output)
1404 # Generate (or update) the ant build file, build.xml...
1405 if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1406 parms = ['android', 'update', 'lib-project']
1407 lparms = ['android', 'update', 'project']
1410 parms += ['-t', build['target']]
1411 lparms += ['-t', build['target']]
1412 if build['update'] == ['auto']:
1413 update_dirs = ant_subprojects(root_dir) + ['.']
1415 update_dirs = build['update']
1417 for d in update_dirs:
1418 subdir = os.path.join(root_dir, d)
1420 logging.debug("Updating main project")
1421 cmd = parms + ['-p', d]
1423 logging.debug("Updating subproject %s" % d)
1424 cmd = lparms + ['-p', d]
1425 p = SdkToolsPopen(cmd, cwd=root_dir)
1426 # Check to see whether an error was returned without a proper exit
1427 # code (this is the case for the 'no target set or target invalid'
1429 if p.returncode != 0 or p.output.startswith("Error: "):
1430 raise BuildException("Failed to update project at %s" % d, p.output)
1431 # Clean update dirs via ant
1433 logging.info("Cleaning subproject %s" % d)
1434 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1436 return (root_dir, srclibpaths)
1439 # Split and extend via globbing the paths from a field
1440 def getpaths(build_dir, build, field):
1442 for p in build[field]:
1444 full_path = os.path.join(build_dir, p)
1445 full_path = os.path.normpath(full_path)
1446 paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1451 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1457 self.path = os.path.join('stats', 'known_apks.txt')
1459 if os.path.isfile(self.path):
1460 for line in file(self.path):
1461 t = line.rstrip().split(' ')
1463 self.apks[t[0]] = (t[1], None)
1465 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1466 self.changed = False
1468 def writeifchanged(self):
1469 if not self.changed:
1472 if not os.path.exists('stats'):
1476 for apk, app in self.apks.iteritems():
1478 line = apk + ' ' + appid
1480 line += ' ' + time.strftime('%Y-%m-%d', added)
1483 with open(self.path, 'w') as f:
1484 for line in sorted(lst, key=natural_key):
1485 f.write(line + '\n')
1487 # Record an apk (if it's new, otherwise does nothing)
1488 # Returns the date it was added.
1489 def recordapk(self, apk, app):
1490 if apk not in self.apks:
1491 self.apks[apk] = (app, time.gmtime(time.time()))
1493 _, added = self.apks[apk]
1496 # Look up information - given the 'apkname', returns (app id, date added/None).
1497 # Or returns None for an unknown apk.
1498 def getapp(self, apkname):
1499 if apkname in self.apks:
1500 return self.apks[apkname]
1503 # Get the most recent 'num' apps added to the repo, as a list of package ids
1504 # with the most recent first.
1505 def getlatest(self, num):
1507 for apk, app in self.apks.iteritems():
1511 if apps[appid] > added:
1515 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1516 lst = [app for app, _ in sortedapps]
1521 def isApkDebuggable(apkfile, config):
1522 """Returns True if the given apk file is debuggable
1524 :param apkfile: full path to the apk to check"""
1526 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1528 if p.returncode != 0:
1529 logging.critical("Failed to get apk manifest information")
1531 for line in p.output.splitlines():
1532 if 'android:debuggable' in line and not line.endswith('0x0'):
1537 class AsynchronousFileReader(threading.Thread):
1540 Helper class to implement asynchronous reading of a file
1541 in a separate thread. Pushes read lines on a queue to
1542 be consumed in another thread.
1545 def __init__(self, fd, queue):
1546 assert isinstance(queue, Queue.Queue)
1547 assert callable(fd.readline)
1548 threading.Thread.__init__(self)
1553 '''The body of the tread: read lines and put them on the queue.'''
1554 for line in iter(self._fd.readline, ''):
1555 self._queue.put(line)
1558 '''Check whether there is no more content to expect.'''
1559 return not self.is_alive() and self._queue.empty()
1567 def SdkToolsPopen(commands, cwd=None, output=True):
1569 if cmd not in config:
1570 config[cmd] = find_sdk_tools_cmd(commands[0])
1571 return FDroidPopen([config[cmd]] + commands[1:],
1572 cwd=cwd, output=output)
1575 def FDroidPopen(commands, cwd=None, output=True):
1577 Run a command and capture the possibly huge output.
1579 :param commands: command and argument list like in subprocess.Popen
1580 :param cwd: optionally specifies a working directory
1581 :returns: A PopenResult.
1587 cwd = os.path.normpath(cwd)
1588 logging.debug("Directory: %s" % cwd)
1589 logging.debug("> %s" % ' '.join(commands))
1591 result = PopenResult()
1594 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1595 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1597 raise BuildException("OSError while trying to execute " +
1598 ' '.join(commands) + ': ' + str(e))
1600 stdout_queue = Queue.Queue()
1601 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1602 stdout_reader.start()
1604 # Check the queue for output (until there is no more to get)
1605 while not stdout_reader.eof():
1606 while not stdout_queue.empty():
1607 line = stdout_queue.get()
1608 if output and options.verbose:
1609 # Output directly to console
1610 sys.stderr.write(line)
1612 result.output += line
1616 result.returncode = p.wait()
1620 def remove_signing_keys(build_dir):
1621 comment = re.compile(r'[ ]*//')
1622 signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1624 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1625 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1626 re.compile(r'.*variant\.outputFile = .*'),
1627 re.compile(r'.*output\.outputFile = .*'),
1628 re.compile(r'.*\.readLine\(.*'),
1630 for root, dirs, files in os.walk(build_dir):
1631 if 'build.gradle' in files:
1632 path = os.path.join(root, 'build.gradle')
1634 with open(path, "r") as o:
1635 lines = o.readlines()
1641 with open(path, "w") as o:
1642 while i < len(lines):
1645 while line.endswith('\\\n'):
1646 line = line.rstrip('\\\n') + lines[i]
1649 if comment.match(line):
1653 opened += line.count('{')
1654 opened -= line.count('}')
1657 if signing_configs.match(line):
1662 if any(s.match(line) for s in line_matches):
1670 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1673 'project.properties',
1675 'default.properties',
1676 'ant.properties', ]:
1677 if propfile in files:
1678 path = os.path.join(root, propfile)
1680 with open(path, "r") as o:
1681 lines = o.readlines()
1685 with open(path, "w") as o:
1687 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1694 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1697 def reset_env_path():
1698 global env, orig_path
1699 env['PATH'] = orig_path
1702 def add_to_env_path(path):
1704 paths = env['PATH'].split(os.pathsep)
1708 env['PATH'] = os.pathsep.join(paths)
1711 def replace_config_vars(cmd, build):
1713 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1714 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1715 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1716 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1717 if build is not None:
1718 cmd = cmd.replace('$$COMMIT$$', build['commit'])
1719 cmd = cmd.replace('$$VERSION$$', build['version'])
1720 cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1724 def place_srclib(root_dir, number, libpath):
1727 relpath = os.path.relpath(libpath, root_dir)
1728 proppath = os.path.join(root_dir, 'project.properties')
1731 if os.path.isfile(proppath):
1732 with open(proppath, "r") as o:
1733 lines = o.readlines()
1735 with open(proppath, "w") as o:
1738 if line.startswith('android.library.reference.%d=' % number):
1739 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1744 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1747 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1748 """Verify that two apks are the same
1750 One of the inputs is signed, the other is unsigned. The signature metadata
1751 is transferred from the signed to the unsigned apk, and then jarsigner is
1752 used to verify that the signature from the signed apk is also varlid for
1754 :param signed_apk: Path to a signed apk file
1755 :param unsigned_apk: Path to an unsigned apk file expected to match it
1756 :param tmp_dir: Path to directory for temporary files
1757 :returns: None if the verification is successful, otherwise a string
1758 describing what went wrong.
1760 sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1761 with ZipFile(signed_apk) as signed_apk_as_zip:
1762 meta_inf_files = ['META-INF/MANIFEST.MF']
1763 for f in signed_apk_as_zip.namelist():
1764 if sigfile.match(f):
1765 meta_inf_files.append(f)
1766 if len(meta_inf_files) < 3:
1767 return "Signature files missing from {0}".format(signed_apk)
1768 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1769 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1770 for meta_inf_file in meta_inf_files:
1771 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1773 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1774 logging.info("...NOT verified - {0}".format(signed_apk))
1775 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1776 logging.info("...successfully verified")
1780 def compare_apks(apk1, apk2, tmp_dir):
1783 Returns None if the apk content is the same (apart from the signing key),
1784 otherwise a string describing what's different, or what went wrong when
1785 trying to do the comparison.
1788 badchars = re.compile('''[/ :;'"]''')
1789 apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4])) # trim .apk
1790 apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4])) # trim .apk
1791 for d in [apk1dir, apk2dir]:
1792 if os.path.exists(d):
1795 os.mkdir(os.path.join(d, 'jar-xf'))
1797 if subprocess.call(['jar', 'xf',
1798 os.path.abspath(apk1)],
1799 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1800 return("Failed to unpack " + apk1)
1801 if subprocess.call(['jar', 'xf',
1802 os.path.abspath(apk2)],
1803 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1804 return("Failed to unpack " + apk2)
1806 # try to find apktool in the path, if it hasn't been manually configed
1807 if 'apktool' not in config:
1808 tmp = find_command('apktool')
1810 config['apktool'] = tmp
1811 if 'apktool' in config:
1812 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1814 return("Failed to unpack " + apk1)
1815 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1817 return("Failed to unpack " + apk2)
1819 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1820 lines = p.output.splitlines()
1821 if len(lines) != 1 or 'META-INF' not in lines[0]:
1822 meld = find_command('meld')
1823 if meld is not None:
1824 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1825 return("Unexpected diff output - " + p.output)
1827 # since everything verifies, delete the comparison to keep cruft down
1828 shutil.rmtree(apk1dir)
1829 shutil.rmtree(apk2dir)
1831 # If we get here, it seems like they're the same!
1835 def find_command(command):
1836 '''find the full path of a command, or None if it can't be found in the PATH'''
1839 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1841 fpath, fname = os.path.split(command)
1846 for path in os.environ["PATH"].split(os.pathsep):
1847 path = path.strip('"')
1848 exe_file = os.path.join(path, command)
1849 if is_exe(exe_file):
1856 '''generate a random password for when generating keys'''
1857 h = hashlib.sha256()
1858 h.update(os.urandom(16)) # salt
1859 h.update(bytes(socket.getfqdn()))
1860 return h.digest().encode('base64').strip()
1863 def genkeystore(localconfig):
1864 '''Generate a new key with random passwords and add it to new keystore'''
1865 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1866 keystoredir = os.path.dirname(localconfig['keystore'])
1867 if keystoredir is None or keystoredir == '':
1868 keystoredir = os.path.join(os.getcwd(), keystoredir)
1869 if not os.path.exists(keystoredir):
1870 os.makedirs(keystoredir, mode=0o700)
1872 write_password_file("keystorepass", localconfig['keystorepass'])
1873 write_password_file("keypass", localconfig['keypass'])
1874 p = FDroidPopen(['keytool', '-genkey',
1875 '-keystore', localconfig['keystore'],
1876 '-alias', localconfig['repo_keyalias'],
1877 '-keyalg', 'RSA', '-keysize', '4096',
1878 '-sigalg', 'SHA256withRSA',
1879 '-validity', '10000',
1880 '-storepass:file', config['keystorepassfile'],
1881 '-keypass:file', config['keypassfile'],
1882 '-dname', localconfig['keydname']])
1883 # TODO keypass should be sent via stdin
1884 if p.returncode != 0:
1885 raise BuildException("Failed to generate key", p.output)
1886 os.chmod(localconfig['keystore'], 0o0600)
1887 # now show the lovely key that was just generated
1888 p = FDroidPopen(['keytool', '-list', '-v',
1889 '-keystore', localconfig['keystore'],
1890 '-alias', localconfig['repo_keyalias'],
1891 '-storepass:file', config['keystorepassfile']])
1892 logging.info(p.output.strip() + '\n\n')
1895 def write_to_config(thisconfig, key, value=None):
1896 '''write a key/value to the local config.py'''
1898 origkey = key + '_orig'
1899 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1900 with open('config.py', 'r') as f:
1902 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1903 repl = '\n' + key + ' = "' + value + '"'
1904 data = re.sub(pattern, repl, data)
1905 # if this key is not in the file, append it
1906 if not re.match('\s*' + key + '\s*=\s*"', data):
1908 # make sure the file ends with a carraige return
1909 if not re.match('\n$', data):
1911 with open('config.py', 'w') as f:
1915 def parse_xml(path):
1916 return XMLElementTree.parse(path).getroot()
1919 def string_is_integer(string):
1927 def get_per_app_repos():
1928 '''per-app repos are dirs named with the packageName of a single app'''
1930 # Android packageNames are Java packages, they may contain uppercase or
1931 # lowercase letters ('A' through 'Z'), numbers, and underscores
1932 # ('_'). However, individual package name parts may only start with
1933 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1934 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1937 for root, dirs, files in os.walk(os.getcwd()):
1939 print 'checking', root, 'for', d
1940 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1941 # standard parts of an fdroid repo, so never packageNames
1944 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):