1 # -*- coding: utf-8 -*-
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
35 import xml.etree.ElementTree as XMLElementTree
39 from Queue import Queue
42 from queue import Queue
44 from zipfile import ZipFile
47 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
50 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
59 'sdk_path': "$ANDROID_HOME",
62 'r10e': "$ANDROID_NDK",
64 'build_tools': "23.0.2",
66 '1.7': "/usr/lib/jvm/java-7-openjdk",
72 'accepted_formats': ['txt', 'yaml'],
73 'sync_from_local_copy_dir': False,
74 'per_app_repos': False,
75 'make_current_version_link': True,
76 'current_version_name_source': 'Name',
77 'update_stats': False,
81 'stats_to_carbon': False,
83 'build_server_always': False,
84 'keystore': 'keystore.jks',
85 'smartcardoptions': [],
91 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
92 'repo_name': "My First FDroid Repo Demo",
93 'repo_icon': "fdroid-icon.png",
94 'repo_description': '''
95 This is a repository of apps to be used with FDroid. Applications in this
96 repository are either official binaries built by the original application
97 developers, or are binaries built from source by the admin of f-droid.org
98 using the tools on https://gitlab.com/u/fdroid.
104 def setup_global_opts(parser):
105 parser.add_argument("-v", "--verbose", action="store_true", default=False,
106 help="Spew out even more information than normal")
107 parser.add_argument("-q", "--quiet", action="store_true", default=False,
108 help="Restrict output to warnings and errors")
111 def fill_config_defaults(thisconfig):
112 for k, v in default_config.items():
113 if k not in thisconfig:
116 # Expand paths (~users and $vars)
117 def expand_path(path):
121 path = os.path.expanduser(path)
122 path = os.path.expandvars(path)
127 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
132 thisconfig[k + '_orig'] = v
134 for k in ['ndk_paths', 'java_paths']:
140 thisconfig[k][k2] = exp
141 thisconfig[k][k2 + '_orig'] = v
144 def regsub_file(pattern, repl, path):
145 with open(path, 'r') as f:
147 text = re.sub(pattern, repl, text)
148 with open(path, 'w') as f:
152 def read_config(opts, config_file='config.py'):
153 """Read the repository config
155 The config is read from config_file, which is in the current directory when
156 any of the repo management commands are used.
158 global config, options, env, orig_path
160 if config is not None:
162 if not os.path.isfile(config_file):
163 logging.critical("Missing config file - is this a repo directory?")
170 logging.debug("Reading %s" % config_file)
171 execfile(config_file, config)
173 # smartcardoptions must be a list since its command line args for Popen
174 if 'smartcardoptions' in config:
175 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
176 elif 'keystore' in config and config['keystore'] == 'NONE':
177 # keystore='NONE' means use smartcard, these are required defaults
178 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
179 'SunPKCS11-OpenSC', '-providerClass',
180 'sun.security.pkcs11.SunPKCS11',
181 '-providerArg', 'opensc-fdroid.cfg']
183 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
184 st = os.stat(config_file)
185 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
186 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
188 fill_config_defaults(config)
190 # There is no standard, so just set up the most common environment
193 orig_path = env['PATH']
194 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
195 env[n] = config['sdk_path']
198 cpath = config['java_paths']['1.%s' % v]
200 env['JAVA%s_HOME' % v] = cpath
202 for k in ["keystorepass", "keypass"]:
204 write_password_file(k)
206 for k in ["repo_description", "archive_description"]:
208 config[k] = clean_description(config[k])
210 if 'serverwebroot' in config:
211 if isinstance(config['serverwebroot'], basestring):
212 roots = [config['serverwebroot']]
213 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
214 roots = config['serverwebroot']
216 raise TypeError('only accepts strings, lists, and tuples')
218 for rootstr in roots:
219 # since this is used with rsync, where trailing slashes have
220 # meaning, ensure there is always a trailing slash
221 if rootstr[-1] != '/':
223 rootlist.append(rootstr.replace('//', '/'))
224 config['serverwebroot'] = rootlist
229 def find_sdk_tools_cmd(cmd):
230 '''find a working path to a tool from the Android SDK'''
233 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
234 # try to find a working path to this command, in all the recent possible paths
235 if 'build_tools' in config:
236 build_tools = os.path.join(config['sdk_path'], 'build-tools')
237 # if 'build_tools' was manually set and exists, check only that one
238 configed_build_tools = os.path.join(build_tools, config['build_tools'])
239 if os.path.exists(configed_build_tools):
240 tooldirs.append(configed_build_tools)
242 # no configed version, so hunt known paths for it
243 for f in sorted(os.listdir(build_tools), reverse=True):
244 if os.path.isdir(os.path.join(build_tools, f)):
245 tooldirs.append(os.path.join(build_tools, f))
246 tooldirs.append(build_tools)
247 sdk_tools = os.path.join(config['sdk_path'], 'tools')
248 if os.path.exists(sdk_tools):
249 tooldirs.append(sdk_tools)
250 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
251 if os.path.exists(sdk_platform_tools):
252 tooldirs.append(sdk_platform_tools)
253 tooldirs.append('/usr/bin')
255 if os.path.isfile(os.path.join(d, cmd)):
256 return os.path.join(d, cmd)
257 # did not find the command, exit with error message
258 ensure_build_tools_exists(config)
261 def test_sdk_exists(thisconfig):
262 if 'sdk_path' not in thisconfig:
263 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
266 logging.error("'sdk_path' not set in config.py!")
268 if thisconfig['sdk_path'] == default_config['sdk_path']:
269 logging.error('No Android SDK found!')
270 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
271 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
273 if not os.path.exists(thisconfig['sdk_path']):
274 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
276 if not os.path.isdir(thisconfig['sdk_path']):
277 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
279 for d in ['build-tools', 'platform-tools', 'tools']:
280 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
281 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
282 thisconfig['sdk_path'], d))
287 def ensure_build_tools_exists(thisconfig):
288 if not test_sdk_exists(thisconfig):
290 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
291 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
292 if not os.path.isdir(versioned_build_tools):
293 logging.critical('Android Build Tools path "'
294 + versioned_build_tools + '" does not exist!')
298 def write_password_file(pwtype, password=None):
300 writes out passwords to a protected file instead of passing passwords as
301 command line argments
303 filename = '.fdroid.' + pwtype + '.txt'
304 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
306 os.write(fd, config[pwtype])
308 os.write(fd, password)
310 config[pwtype + 'file'] = filename
313 # Given the arguments in the form of multiple appid:[vc] strings, this returns
314 # a dictionary with the set of vercodes specified for each package.
315 def read_pkg_args(args, allow_vercodes=False):
322 if allow_vercodes and ':' in p:
323 package, vercode = p.split(':')
325 package, vercode = p, None
326 if package not in vercodes:
327 vercodes[package] = [vercode] if vercode else []
329 elif vercode and vercode not in vercodes[package]:
330 vercodes[package] += [vercode] if vercode else []
335 # On top of what read_pkg_args does, this returns the whole app metadata, but
336 # limiting the builds list to the builds matching the vercodes specified.
337 def read_app_args(args, allapps, allow_vercodes=False):
339 vercodes = read_pkg_args(args, allow_vercodes)
345 for appid, app in allapps.iteritems():
346 if appid in vercodes:
349 if len(apps) != len(vercodes):
352 logging.critical("No such package: %s" % p)
353 raise FDroidException("Found invalid app ids in arguments")
355 raise FDroidException("No packages specified")
358 for appid, app in apps.iteritems():
362 app.builds = [b for b in app.builds if b.vercode in vc]
363 if len(app.builds) != len(vercodes[appid]):
365 allvcs = [b.vercode for b in app.builds]
366 for v in vercodes[appid]:
368 logging.critical("No such vercode %s for app %s" % (v, appid))
371 raise FDroidException("Found invalid vercodes for some apps")
376 def get_extension(filename):
377 base, ext = os.path.splitext(filename)
380 return base, ext.lower()[1:]
383 def has_extension(filename, ext):
384 _, f_ext = get_extension(filename)
388 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
391 def clean_description(description):
392 'Remove unneeded newlines and spaces from a block of description text'
394 # this is split up by paragraph to make removing the newlines easier
395 for paragraph in re.split(r'\n\n', description):
396 paragraph = re.sub('\r', '', paragraph)
397 paragraph = re.sub('\n', ' ', paragraph)
398 paragraph = re.sub(' {2,}', ' ', paragraph)
399 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
400 returnstring += paragraph + '\n\n'
401 return returnstring.rstrip('\n')
404 def apknameinfo(filename):
405 filename = os.path.basename(filename)
406 m = apk_regex.match(filename)
408 result = (m.group(1), m.group(2))
409 except AttributeError:
410 raise FDroidException("Invalid apk name: %s" % filename)
414 def getapkname(app, build):
415 return "%s_%s.apk" % (app.id, build.vercode)
418 def getsrcname(app, build):
419 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
431 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
434 def getvcs(vcstype, remote, local):
436 return vcs_git(remote, local)
437 if vcstype == 'git-svn':
438 return vcs_gitsvn(remote, local)
440 return vcs_hg(remote, local)
442 return vcs_bzr(remote, local)
443 if vcstype == 'srclib':
444 if local != os.path.join('build', 'srclib', remote):
445 raise VCSException("Error: srclib paths are hard-coded!")
446 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
448 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
449 raise VCSException("Invalid vcs type " + vcstype)
452 def getsrclibvcs(name):
453 if name not in metadata.srclibs:
454 raise VCSException("Missing srclib " + name)
455 return metadata.srclibs[name]['Repo Type']
460 def __init__(self, remote, local):
462 # svn, git-svn and bzr may require auth
464 if self.repotype() in ('git-svn', 'bzr'):
466 if self.repotype == 'git-svn':
467 raise VCSException("Authentication is not supported for git-svn")
468 self.username, remote = remote.split('@')
469 if ':' not in self.username:
470 raise VCSException("Password required with username")
471 self.username, self.password = self.username.split(':')
475 self.clone_failed = False
476 self.refreshed = False
482 # Take the local repository to a clean version of the given revision, which
483 # is specificed in the VCS's native format. Beforehand, the repository can
484 # be dirty, or even non-existent. If the repository does already exist
485 # locally, it will be updated from the origin, but only once in the
486 # lifetime of the vcs object.
487 # None is acceptable for 'rev' if you know you are cloning a clean copy of
488 # the repo - otherwise it must specify a valid revision.
489 def gotorevision(self, rev, refresh=True):
491 if self.clone_failed:
492 raise VCSException("Downloading the repository already failed once, not trying again.")
494 # The .fdroidvcs-id file for a repo tells us what VCS type
495 # and remote that directory was created from, allowing us to drop it
496 # automatically if either of those things changes.
497 fdpath = os.path.join(self.local, '..',
498 '.fdroidvcs-' + os.path.basename(self.local))
499 cdata = self.repotype() + ' ' + self.remote
502 if os.path.exists(self.local):
503 if os.path.exists(fdpath):
504 with open(fdpath, 'r') as f:
505 fsdata = f.read().strip()
510 logging.info("Repository details for %s changed - deleting" % (
514 logging.info("Repository details for %s missing - deleting" % (
517 shutil.rmtree(self.local)
521 self.refreshed = True
524 self.gotorevisionx(rev)
525 except FDroidException as e:
528 # If necessary, write the .fdroidvcs file.
529 if writeback and not self.clone_failed:
530 with open(fdpath, 'w') as f:
536 # Derived classes need to implement this. It's called once basic checking
537 # has been performend.
538 def gotorevisionx(self, rev):
539 raise VCSException("This VCS type doesn't define gotorevisionx")
541 # Initialise and update submodules
542 def initsubmodules(self):
543 raise VCSException('Submodules not supported for this vcs type')
545 # Get a list of all known tags
547 if not self._gettags:
548 raise VCSException('gettags not supported for this vcs type')
550 for tag in self._gettags():
551 if re.match('[-A-Za-z0-9_. /]+$', tag):
555 def latesttags(self, tags, number):
556 """Get the most recent tags in a given list.
558 :param tags: a list of tags
559 :param number: the number to return
560 :returns: A list containing the most recent tags in the provided
561 list, up to the maximum number given.
563 raise VCSException('latesttags not supported for this vcs type')
565 # Get current commit reference (hash, revision, etc)
567 raise VCSException('getref not supported for this vcs type')
569 # Returns the srclib (name, path) used in setting up the current
580 # If the local directory exists, but is somehow not a git repository, git
581 # will traverse up the directory tree until it finds one that is (i.e.
582 # fdroidserver) and then we'll proceed to destroy it! This is called as
585 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
586 result = p.output.rstrip()
587 if not result.endswith(self.local):
588 raise VCSException('Repository mismatch')
590 def gotorevisionx(self, rev):
591 if not os.path.exists(self.local):
593 p = FDroidPopen(['git', 'clone', self.remote, self.local])
594 if p.returncode != 0:
595 self.clone_failed = True
596 raise VCSException("Git clone failed", p.output)
600 # Discard any working tree changes
601 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
602 'git', 'reset', '--hard'], cwd=self.local, output=False)
603 if p.returncode != 0:
604 raise VCSException("Git reset failed", p.output)
605 # Remove untracked files now, in case they're tracked in the target
606 # revision (it happens!)
607 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
608 'git', 'clean', '-dffx'], cwd=self.local, output=False)
609 if p.returncode != 0:
610 raise VCSException("Git clean failed", p.output)
611 if not self.refreshed:
612 # Get latest commits and tags from remote
613 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
614 if p.returncode != 0:
615 raise VCSException("Git fetch failed", p.output)
616 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
617 if p.returncode != 0:
618 raise VCSException("Git fetch failed", p.output)
619 # Recreate origin/HEAD as git clone would do it, in case it disappeared
620 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
621 if p.returncode != 0:
622 lines = p.output.splitlines()
623 if 'Multiple remote HEAD branches' not in lines[0]:
624 raise VCSException("Git remote set-head failed", p.output)
625 branch = lines[1].split(' ')[-1]
626 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
627 if p2.returncode != 0:
628 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
629 self.refreshed = True
630 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
631 # a github repo. Most of the time this is the same as origin/master.
632 rev = rev or 'origin/HEAD'
633 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
634 if p.returncode != 0:
635 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
636 # Get rid of any uncontrolled files left behind
637 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
638 if p.returncode != 0:
639 raise VCSException("Git clean failed", p.output)
641 def initsubmodules(self):
643 submfile = os.path.join(self.local, '.gitmodules')
644 if not os.path.isfile(submfile):
645 raise VCSException("No git submodules available")
647 # fix submodules not accessible without an account and public key auth
648 with open(submfile, 'r') as f:
649 lines = f.readlines()
650 with open(submfile, 'w') as f:
652 if 'git@github.com' in line:
653 line = line.replace('git@github.com:', 'https://github.com/')
654 if 'git@gitlab.com' in line:
655 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
658 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
659 if p.returncode != 0:
660 raise VCSException("Git submodule sync failed", p.output)
661 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
662 if p.returncode != 0:
663 raise VCSException("Git submodule update failed", p.output)
667 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
668 return p.output.splitlines()
670 def latesttags(self, tags, number):
675 ['git', 'show', '--format=format:%ct', '-s', tag],
676 cwd=self.local, output=False)
677 # Timestamp is on the last line. For a normal tag, it's the only
678 # line, but for annotated tags, the rest of the info precedes it.
679 ts = int(p.output.splitlines()[-1])
682 for _, t in sorted(tl)[-number:]:
687 class vcs_gitsvn(vcs):
692 # If the local directory exists, but is somehow not a git repository, git
693 # will traverse up the directory tree until it finds one that is (i.e.
694 # fdroidserver) and then we'll proceed to destory it! This is called as
697 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
698 result = p.output.rstrip()
699 if not result.endswith(self.local):
700 raise VCSException('Repository mismatch')
702 def gotorevisionx(self, rev):
703 if not os.path.exists(self.local):
705 gitsvn_args = ['git', 'svn', 'clone']
706 if ';' in self.remote:
707 remote_split = self.remote.split(';')
708 for i in remote_split[1:]:
709 if i.startswith('trunk='):
710 gitsvn_args.extend(['-T', i[6:]])
711 elif i.startswith('tags='):
712 gitsvn_args.extend(['-t', i[5:]])
713 elif i.startswith('branches='):
714 gitsvn_args.extend(['-b', i[9:]])
715 gitsvn_args.extend([remote_split[0], self.local])
716 p = FDroidPopen(gitsvn_args, output=False)
717 if p.returncode != 0:
718 self.clone_failed = True
719 raise VCSException("Git svn clone failed", p.output)
721 gitsvn_args.extend([self.remote, self.local])
722 p = FDroidPopen(gitsvn_args, output=False)
723 if p.returncode != 0:
724 self.clone_failed = True
725 raise VCSException("Git svn clone failed", p.output)
729 # Discard any working tree changes
730 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
731 if p.returncode != 0:
732 raise VCSException("Git reset failed", p.output)
733 # Remove untracked files now, in case they're tracked in the target
734 # revision (it happens!)
735 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
736 if p.returncode != 0:
737 raise VCSException("Git clean failed", p.output)
738 if not self.refreshed:
739 # Get new commits, branches and tags from repo
740 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
741 if p.returncode != 0:
742 raise VCSException("Git svn fetch failed")
743 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
744 if p.returncode != 0:
745 raise VCSException("Git svn rebase failed", p.output)
746 self.refreshed = True
748 rev = rev or 'master'
750 nospaces_rev = rev.replace(' ', '%20')
751 # Try finding a svn tag
752 for treeish in ['origin/', '']:
753 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
754 if p.returncode == 0:
756 if p.returncode != 0:
757 # No tag found, normal svn rev translation
758 # Translate svn rev into git format
759 rev_split = rev.split('/')
762 for treeish in ['origin/', '']:
763 if len(rev_split) > 1:
764 treeish += rev_split[0]
765 svn_rev = rev_split[1]
768 # if no branch is specified, then assume trunk (i.e. 'master' branch):
772 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
774 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
775 git_rev = p.output.rstrip()
777 if p.returncode == 0 and git_rev:
780 if p.returncode != 0 or not git_rev:
781 # Try a plain git checkout as a last resort
782 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
783 if p.returncode != 0:
784 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
786 # Check out the git rev equivalent to the svn rev
787 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
788 if p.returncode != 0:
789 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
791 # Get rid of any uncontrolled files left behind
792 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
793 if p.returncode != 0:
794 raise VCSException("Git clean failed", p.output)
798 for treeish in ['origin/', '']:
799 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
805 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
806 if p.returncode != 0:
808 return p.output.strip()
816 def gotorevisionx(self, rev):
817 if not os.path.exists(self.local):
818 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
819 if p.returncode != 0:
820 self.clone_failed = True
821 raise VCSException("Hg clone failed", p.output)
823 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
824 if p.returncode != 0:
825 raise VCSException("Hg status failed", p.output)
826 for line in p.output.splitlines():
827 if not line.startswith('? '):
828 raise VCSException("Unexpected output from hg status -uS: " + line)
829 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
830 if not self.refreshed:
831 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
832 if p.returncode != 0:
833 raise VCSException("Hg pull failed", p.output)
834 self.refreshed = True
836 rev = rev or 'default'
839 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
840 if p.returncode != 0:
841 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
842 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
843 # Also delete untracked files, we have to enable purge extension for that:
844 if "'purge' is provided by the following extension" in p.output:
845 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
846 myfile.write("\n[extensions]\nhgext.purge=\n")
847 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
848 if p.returncode != 0:
849 raise VCSException("HG purge failed", p.output)
850 elif p.returncode != 0:
851 raise VCSException("HG purge failed", p.output)
854 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
855 return p.output.splitlines()[1:]
863 def gotorevisionx(self, rev):
864 if not os.path.exists(self.local):
865 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
866 if p.returncode != 0:
867 self.clone_failed = True
868 raise VCSException("Bzr branch failed", p.output)
870 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Bzr revert failed", p.output)
873 if not self.refreshed:
874 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
875 if p.returncode != 0:
876 raise VCSException("Bzr update failed", p.output)
877 self.refreshed = True
879 revargs = list(['-r', rev] if rev else [])
880 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
881 if p.returncode != 0:
882 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
885 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
886 return [tag.split(' ')[0].strip() for tag in
887 p.output.splitlines()]
890 def unescape_string(string):
893 if string[0] == '"' and string[-1] == '"':
896 return string.replace("\\'", "'")
899 def retrieve_string(app_dir, string, xmlfiles=None):
901 if not string.startswith('@string/'):
902 return unescape_string(string)
907 os.path.join(app_dir, 'res'),
908 os.path.join(app_dir, 'src', 'main', 'res'),
910 for r, d, f in os.walk(res_dir):
911 if os.path.basename(r) == 'values':
912 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
914 name = string[len('@string/'):]
916 def element_content(element):
917 if element.text is None:
919 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
922 for path in xmlfiles:
923 if not os.path.isfile(path):
925 xml = parse_xml(path)
926 element = xml.find('string[@name="' + name + '"]')
927 if element is not None:
928 content = element_content(element)
929 return retrieve_string(app_dir, content, xmlfiles)
934 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
935 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
938 # Return list of existing files that will be used to find the highest vercode
939 def manifest_paths(app_dir, flavours):
941 possible_manifests = \
942 [os.path.join(app_dir, 'AndroidManifest.xml'),
943 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
944 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
945 os.path.join(app_dir, 'build.gradle')]
947 for flavour in flavours:
950 possible_manifests.append(
951 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
953 return [path for path in possible_manifests if os.path.isfile(path)]
956 # Retrieve the package name. Returns the name, or None if not found.
957 def fetch_real_name(app_dir, flavours):
958 for path in manifest_paths(app_dir, flavours):
959 if not has_extension(path, 'xml') or not os.path.isfile(path):
961 logging.debug("fetch_real_name: Checking manifest at " + path)
962 xml = parse_xml(path)
963 app = xml.find('application')
966 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
968 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
969 result = retrieve_string_singleline(app_dir, label)
971 result = result.strip()
976 def get_library_references(root_dir):
978 proppath = os.path.join(root_dir, 'project.properties')
979 if not os.path.isfile(proppath):
981 for line in file(proppath):
982 if not line.startswith('android.library.reference.'):
984 path = line.split('=')[1].strip()
985 relpath = os.path.join(root_dir, path)
986 if not os.path.isdir(relpath):
988 logging.debug("Found subproject at %s" % path)
989 libraries.append(path)
993 def ant_subprojects(root_dir):
994 subprojects = get_library_references(root_dir)
995 for subpath in subprojects:
996 subrelpath = os.path.join(root_dir, subpath)
997 for p in get_library_references(subrelpath):
998 relp = os.path.normpath(os.path.join(subpath, p))
999 if relp not in subprojects:
1000 subprojects.insert(0, relp)
1004 def remove_debuggable_flags(root_dir):
1005 # Remove forced debuggable flags
1006 logging.debug("Removing debuggable flags from %s" % root_dir)
1007 for root, dirs, files in os.walk(root_dir):
1008 if 'AndroidManifest.xml' in files:
1009 regsub_file(r'android:debuggable="[^"]*"',
1011 os.path.join(root, 'AndroidManifest.xml'))
1014 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1015 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1016 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1019 def app_matches_packagename(app, package):
1022 appid = app.UpdateCheckName or app.id
1023 if appid is None or appid == "Ignore":
1025 return appid == package
1028 # Extract some information from the AndroidManifest.xml at the given path.
1029 # Returns (version, vercode, package), any or all of which might be None.
1030 # All values returned are strings.
1031 def parse_androidmanifests(paths, app):
1033 ignoreversions = app.UpdateCheckIgnore
1034 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1037 return (None, None, None)
1045 if not os.path.isfile(path):
1048 logging.debug("Parsing manifest at {0}".format(path))
1049 gradle = has_extension(path, 'gradle')
1055 for line in file(path):
1056 if gradle_comment.match(line):
1058 # Grab first occurence of each to avoid running into
1059 # alternative flavours and builds.
1061 matches = psearch_g(line)
1063 s = matches.group(2)
1064 if app_matches_packagename(app, s):
1067 matches = vnsearch_g(line)
1069 version = matches.group(2)
1071 matches = vcsearch_g(line)
1073 vercode = matches.group(1)
1075 xml = parse_xml(path)
1076 if "package" in xml.attrib:
1077 s = xml.attrib["package"].encode('utf-8')
1078 if app_matches_packagename(app, s):
1080 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1081 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1082 base_dir = os.path.dirname(path)
1083 version = retrieve_string_singleline(base_dir, version)
1084 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1085 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1086 if string_is_integer(a):
1089 # Remember package name, may be defined separately from version+vercode
1091 package = max_package
1093 logging.debug("..got package={0}, version={1}, vercode={2}"
1094 .format(package, version, vercode))
1096 # Always grab the package name and version name in case they are not
1097 # together with the highest version code
1098 if max_package is None and package is not None:
1099 max_package = package
1100 if max_version is None and version is not None:
1101 max_version = version
1103 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1104 if not ignoresearch or not ignoresearch(version):
1105 if version is not None:
1106 max_version = version
1107 if vercode is not None:
1108 max_vercode = vercode
1109 if package is not None:
1110 max_package = package
1112 max_version = "Ignore"
1114 if max_version is None:
1115 max_version = "Unknown"
1117 if max_package and not is_valid_package_name(max_package):
1118 raise FDroidException("Invalid package name {0}".format(max_package))
1120 return (max_version, max_vercode, max_package)
1123 def is_valid_package_name(name):
1124 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1127 class FDroidException(Exception):
1129 def __init__(self, value, detail=None):
1131 self.detail = detail
1133 def shortened_detail(self):
1134 if len(self.detail) < 16000:
1136 return '[...]\n' + self.detail[-16000:]
1138 def get_wikitext(self):
1139 ret = repr(self.value) + "\n"
1142 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1148 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1152 class VCSException(FDroidException):
1156 class BuildException(FDroidException):
1160 # Get the specified source library.
1161 # Returns the path to it. Normally this is the path to be used when referencing
1162 # it, which may be a subdirectory of the actual project. If you want the base
1163 # directory of the project, pass 'basepath=True'.
1164 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1165 raw=False, prepare=True, preponly=False, refresh=True):
1173 name, ref = spec.split('@')
1175 number, name = name.split(':', 1)
1177 name, subdir = name.split('/', 1)
1179 if name not in metadata.srclibs:
1180 raise VCSException('srclib ' + name + ' not found.')
1182 srclib = metadata.srclibs[name]
1184 sdir = os.path.join(srclib_dir, name)
1187 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1188 vcs.srclib = (name, number, sdir)
1190 vcs.gotorevision(ref, refresh)
1197 libdir = os.path.join(sdir, subdir)
1198 elif srclib["Subdir"]:
1199 for subdir in srclib["Subdir"]:
1200 libdir_candidate = os.path.join(sdir, subdir)
1201 if os.path.exists(libdir_candidate):
1202 libdir = libdir_candidate
1208 remove_signing_keys(sdir)
1209 remove_debuggable_flags(sdir)
1213 if srclib["Prepare"]:
1214 cmd = replace_config_vars(srclib["Prepare"], None)
1216 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1217 if p.returncode != 0:
1218 raise BuildException("Error running prepare command for srclib %s"
1224 return (name, number, libdir)
1226 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1229 # Prepare the source code for a particular build
1230 # 'vcs' - the appropriate vcs object for the application
1231 # 'app' - the application details from the metadata
1232 # 'build' - the build details from the metadata
1233 # 'build_dir' - the path to the build directory, usually
1235 # 'srclib_dir' - the path to the source libraries directory, usually
1237 # 'extlib_dir' - the path to the external libraries directory, usually
1239 # Returns the (root, srclibpaths) where:
1240 # 'root' is the root directory, which may be the same as 'build_dir' or may
1241 # be a subdirectory of it.
1242 # 'srclibpaths' is information on the srclibs being used
1243 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1245 # Optionally, the actual app source can be in a subdirectory
1247 root_dir = os.path.join(build_dir, build.subdir)
1249 root_dir = build_dir
1251 # Get a working copy of the right revision
1252 logging.info("Getting source for revision " + build.commit)
1253 vcs.gotorevision(build.commit, refresh)
1255 # Initialise submodules if required
1256 if build.submodules:
1257 logging.info("Initialising submodules")
1258 vcs.initsubmodules()
1260 # Check that a subdir (if we're using one) exists. This has to happen
1261 # after the checkout, since it might not exist elsewhere
1262 if not os.path.exists(root_dir):
1263 raise BuildException('Missing subdir ' + root_dir)
1265 # Run an init command if one is required
1267 cmd = replace_config_vars(build.init, build)
1268 logging.info("Running 'init' commands in %s" % root_dir)
1270 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1271 if p.returncode != 0:
1272 raise BuildException("Error running init command for %s:%s" %
1273 (app.id, build.version), p.output)
1275 # Apply patches if any
1277 logging.info("Applying patches")
1278 for patch in build.patch:
1279 patch = patch.strip()
1280 logging.info("Applying " + patch)
1281 patch_path = os.path.join('metadata', app.id, patch)
1282 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1283 if p.returncode != 0:
1284 raise BuildException("Failed to apply patch %s" % patch_path)
1286 # Get required source libraries
1289 logging.info("Collecting source libraries")
1290 for lib in build.srclibs:
1291 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1293 for name, number, libpath in srclibpaths:
1294 place_srclib(root_dir, int(number) if number else None, libpath)
1296 basesrclib = vcs.getsrclib()
1297 # If one was used for the main source, add that too.
1299 srclibpaths.append(basesrclib)
1301 # Update the local.properties file
1302 localprops = [os.path.join(build_dir, 'local.properties')]
1304 parts = build.subdir.split(os.sep)
1307 cur = os.path.join(cur, d)
1308 localprops += [os.path.join(cur, 'local.properties')]
1309 for path in localprops:
1311 if os.path.isfile(path):
1312 logging.info("Updating local.properties file at %s" % path)
1313 with open(path, 'r') as f:
1317 logging.info("Creating local.properties file at %s" % path)
1318 # Fix old-fashioned 'sdk-location' by copying
1319 # from sdk.dir, if necessary
1321 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1322 re.S | re.M).group(1)
1323 props += "sdk-location=%s\n" % sdkloc
1325 props += "sdk.dir=%s\n" % config['sdk_path']
1326 props += "sdk-location=%s\n" % config['sdk_path']
1327 ndk_path = build.ndk_path()
1330 props += "ndk.dir=%s\n" % ndk_path
1331 props += "ndk-location=%s\n" % ndk_path
1332 # Add java.encoding if necessary
1334 props += "java.encoding=%s\n" % build.encoding
1335 with open(path, 'w') as f:
1339 if build.method() == 'gradle':
1340 flavours = build.gradle
1343 n = build.target.split('-')[1]
1344 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1345 r'compileSdkVersion %s' % n,
1346 os.path.join(root_dir, 'build.gradle'))
1348 # Remove forced debuggable flags
1349 remove_debuggable_flags(root_dir)
1351 # Insert version code and number into the manifest if necessary
1352 if build.forceversion:
1353 logging.info("Changing the version name")
1354 for path in manifest_paths(root_dir, flavours):
1355 if not os.path.isfile(path):
1357 if has_extension(path, 'xml'):
1358 regsub_file(r'android:versionName="[^"]*"',
1359 r'android:versionName="%s"' % build.version,
1361 elif has_extension(path, 'gradle'):
1362 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1363 r"""\1versionName '%s'""" % build.version,
1366 if build.forcevercode:
1367 logging.info("Changing the version code")
1368 for path in manifest_paths(root_dir, flavours):
1369 if not os.path.isfile(path):
1371 if has_extension(path, 'xml'):
1372 regsub_file(r'android:versionCode="[^"]*"',
1373 r'android:versionCode="%s"' % build.vercode,
1375 elif has_extension(path, 'gradle'):
1376 regsub_file(r'versionCode[ =]+[0-9]+',
1377 r'versionCode %s' % build.vercode,
1380 # Delete unwanted files
1382 logging.info("Removing specified files")
1383 for part in getpaths(build_dir, build.rm):
1384 dest = os.path.join(build_dir, part)
1385 logging.info("Removing {0}".format(part))
1386 if os.path.lexists(dest):
1387 if os.path.islink(dest):
1388 FDroidPopen(['unlink', dest], output=False)
1390 FDroidPopen(['rm', '-rf', dest], output=False)
1392 logging.info("...but it didn't exist")
1394 remove_signing_keys(build_dir)
1396 # Add required external libraries
1398 logging.info("Collecting prebuilt libraries")
1399 libsdir = os.path.join(root_dir, 'libs')
1400 if not os.path.exists(libsdir):
1402 for lib in build.extlibs:
1404 logging.info("...installing extlib {0}".format(lib))
1405 libf = os.path.basename(lib)
1406 libsrc = os.path.join(extlib_dir, lib)
1407 if not os.path.exists(libsrc):
1408 raise BuildException("Missing extlib file {0}".format(libsrc))
1409 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1411 # Run a pre-build command if one is required
1413 logging.info("Running 'prebuild' commands in %s" % root_dir)
1415 cmd = replace_config_vars(build.prebuild, build)
1417 # Substitute source library paths into prebuild commands
1418 for name, number, libpath in srclibpaths:
1419 libpath = os.path.relpath(libpath, root_dir)
1420 cmd = cmd.replace('$$' + name + '$$', libpath)
1422 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1423 if p.returncode != 0:
1424 raise BuildException("Error running prebuild command for %s:%s" %
1425 (app.id, build.version), p.output)
1427 # Generate (or update) the ant build file, build.xml...
1428 if build.method() == 'ant' and build.update != ['no']:
1429 parms = ['android', 'update', 'lib-project']
1430 lparms = ['android', 'update', 'project']
1433 parms += ['-t', build.target]
1434 lparms += ['-t', build.target]
1436 update_dirs = build.update
1438 update_dirs = ant_subprojects(root_dir) + ['.']
1440 for d in update_dirs:
1441 subdir = os.path.join(root_dir, d)
1443 logging.debug("Updating main project")
1444 cmd = parms + ['-p', d]
1446 logging.debug("Updating subproject %s" % d)
1447 cmd = lparms + ['-p', d]
1448 p = SdkToolsPopen(cmd, cwd=root_dir)
1449 # Check to see whether an error was returned without a proper exit
1450 # code (this is the case for the 'no target set or target invalid'
1452 if p.returncode != 0 or p.output.startswith("Error: "):
1453 raise BuildException("Failed to update project at %s" % d, p.output)
1454 # Clean update dirs via ant
1456 logging.info("Cleaning subproject %s" % d)
1457 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1459 return (root_dir, srclibpaths)
1462 # Extend via globbing the paths from a field and return them as a map from
1463 # original path to resulting paths
1464 def getpaths_map(build_dir, globpaths):
1468 full_path = os.path.join(build_dir, p)
1469 full_path = os.path.normpath(full_path)
1470 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1472 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1476 # Extend via globbing the paths from a field and return them as a set
1477 def getpaths(build_dir, globpaths):
1478 paths_map = getpaths_map(build_dir, globpaths)
1480 for k, v in paths_map.iteritems():
1487 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1493 self.path = os.path.join('stats', 'known_apks.txt')
1495 if os.path.isfile(self.path):
1496 for line in file(self.path):
1497 t = line.rstrip().split(' ')
1499 self.apks[t[0]] = (t[1], None)
1501 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1502 self.changed = False
1504 def writeifchanged(self):
1505 if not self.changed:
1508 if not os.path.exists('stats'):
1512 for apk, app in self.apks.iteritems():
1514 line = apk + ' ' + appid
1516 line += ' ' + time.strftime('%Y-%m-%d', added)
1519 with open(self.path, 'w') as f:
1520 for line in sorted(lst, key=natural_key):
1521 f.write(line + '\n')
1523 # Record an apk (if it's new, otherwise does nothing)
1524 # Returns the date it was added.
1525 def recordapk(self, apk, app):
1526 if apk not in self.apks:
1527 self.apks[apk] = (app, time.gmtime(time.time()))
1529 _, added = self.apks[apk]
1532 # Look up information - given the 'apkname', returns (app id, date added/None).
1533 # Or returns None for an unknown apk.
1534 def getapp(self, apkname):
1535 if apkname in self.apks:
1536 return self.apks[apkname]
1539 # Get the most recent 'num' apps added to the repo, as a list of package ids
1540 # with the most recent first.
1541 def getlatest(self, num):
1543 for apk, app in self.apks.iteritems():
1547 if apps[appid] > added:
1551 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1552 lst = [app for app, _ in sortedapps]
1557 def isApkDebuggable(apkfile, config):
1558 """Returns True if the given apk file is debuggable
1560 :param apkfile: full path to the apk to check"""
1562 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1564 if p.returncode != 0:
1565 logging.critical("Failed to get apk manifest information")
1567 for line in p.output.splitlines():
1568 if 'android:debuggable' in line and not line.endswith('0x0'):
1578 def SdkToolsPopen(commands, cwd=None, output=True):
1580 if cmd not in config:
1581 config[cmd] = find_sdk_tools_cmd(commands[0])
1582 abscmd = config[cmd]
1584 logging.critical("Could not find '%s' on your system" % cmd)
1586 return FDroidPopen([abscmd] + commands[1:],
1587 cwd=cwd, output=output)
1590 def FDroidPopen(commands, cwd=None, output=True):
1592 Run a command and capture the possibly huge output.
1594 :param commands: command and argument list like in subprocess.Popen
1595 :param cwd: optionally specifies a working directory
1596 :returns: A PopenResult.
1602 cwd = os.path.normpath(cwd)
1603 logging.debug("Directory: %s" % cwd)
1604 logging.debug("> %s" % ' '.join(commands))
1606 result = PopenResult()
1609 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1610 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1611 except OSError as e:
1612 raise BuildException("OSError while trying to execute " +
1613 ' '.join(commands) + ': ' + str(e))
1615 stdout_queue = Queue()
1616 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1618 # Check the queue for output (until there is no more to get)
1619 while not stdout_reader.eof():
1620 while not stdout_queue.empty():
1621 line = stdout_queue.get()
1622 if output and options.verbose:
1623 # Output directly to console
1624 sys.stderr.write(line)
1626 result.output += line
1630 result.returncode = p.wait()
1634 gradle_comment = re.compile(r'[ ]*//')
1635 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1636 gradle_line_matches = [
1637 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1638 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1639 re.compile(r'.*variant\.outputFile = .*'),
1640 re.compile(r'.*output\.outputFile = .*'),
1641 re.compile(r'.*\.readLine\(.*'),
1645 def remove_signing_keys(build_dir):
1646 for root, dirs, files in os.walk(build_dir):
1647 if 'build.gradle' in files:
1648 path = os.path.join(root, 'build.gradle')
1650 with open(path, "r") as o:
1651 lines = o.readlines()
1657 with open(path, "w") as o:
1658 while i < len(lines):
1661 while line.endswith('\\\n'):
1662 line = line.rstrip('\\\n') + lines[i]
1665 if gradle_comment.match(line):
1670 opened += line.count('{')
1671 opened -= line.count('}')
1674 if gradle_signing_configs.match(line):
1679 if any(s.match(line) for s in gradle_line_matches):
1687 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1690 'project.properties',
1692 'default.properties',
1693 'ant.properties', ]:
1694 if propfile in files:
1695 path = os.path.join(root, propfile)
1697 with open(path, "r") as o:
1698 lines = o.readlines()
1702 with open(path, "w") as o:
1704 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1711 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1714 def reset_env_path():
1715 global env, orig_path
1716 env['PATH'] = orig_path
1719 def add_to_env_path(path):
1721 paths = env['PATH'].split(os.pathsep)
1725 env['PATH'] = os.pathsep.join(paths)
1728 def replace_config_vars(cmd, build):
1730 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1731 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1732 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1733 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1734 if build is not None:
1735 cmd = cmd.replace('$$COMMIT$$', build.commit)
1736 cmd = cmd.replace('$$VERSION$$', build.version)
1737 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1741 def place_srclib(root_dir, number, libpath):
1744 relpath = os.path.relpath(libpath, root_dir)
1745 proppath = os.path.join(root_dir, 'project.properties')
1748 if os.path.isfile(proppath):
1749 with open(proppath, "r") as o:
1750 lines = o.readlines()
1752 with open(proppath, "w") as o:
1755 if line.startswith('android.library.reference.%d=' % number):
1756 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1761 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1763 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1766 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1767 """Verify that two apks are the same
1769 One of the inputs is signed, the other is unsigned. The signature metadata
1770 is transferred from the signed to the unsigned apk, and then jarsigner is
1771 used to verify that the signature from the signed apk is also varlid for
1773 :param signed_apk: Path to a signed apk file
1774 :param unsigned_apk: Path to an unsigned apk file expected to match it
1775 :param tmp_dir: Path to directory for temporary files
1776 :returns: None if the verification is successful, otherwise a string
1777 describing what went wrong.
1779 with ZipFile(signed_apk) as signed_apk_as_zip:
1780 meta_inf_files = ['META-INF/MANIFEST.MF']
1781 for f in signed_apk_as_zip.namelist():
1782 if apk_sigfile.match(f):
1783 meta_inf_files.append(f)
1784 if len(meta_inf_files) < 3:
1785 return "Signature files missing from {0}".format(signed_apk)
1786 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1787 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1788 for meta_inf_file in meta_inf_files:
1789 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1791 if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1792 logging.info("...NOT verified - {0}".format(signed_apk))
1793 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1794 logging.info("...successfully verified")
1797 apk_badchars = re.compile('''[/ :;'"]''')
1800 def compare_apks(apk1, apk2, tmp_dir):
1803 Returns None if the apk content is the same (apart from the signing key),
1804 otherwise a string describing what's different, or what went wrong when
1805 trying to do the comparison.
1808 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1809 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1810 for d in [apk1dir, apk2dir]:
1811 if os.path.exists(d):
1814 os.mkdir(os.path.join(d, 'jar-xf'))
1816 if subprocess.call(['jar', 'xf',
1817 os.path.abspath(apk1)],
1818 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1819 return("Failed to unpack " + apk1)
1820 if subprocess.call(['jar', 'xf',
1821 os.path.abspath(apk2)],
1822 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1823 return("Failed to unpack " + apk2)
1825 # try to find apktool in the path, if it hasn't been manually configed
1826 if 'apktool' not in config:
1827 tmp = find_command('apktool')
1829 config['apktool'] = tmp
1830 if 'apktool' in config:
1831 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1833 return("Failed to unpack " + apk1)
1834 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1836 return("Failed to unpack " + apk2)
1838 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1839 lines = p.output.splitlines()
1840 if len(lines) != 1 or 'META-INF' not in lines[0]:
1841 meld = find_command('meld')
1842 if meld is not None:
1843 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1844 return("Unexpected diff output - " + p.output)
1846 # since everything verifies, delete the comparison to keep cruft down
1847 shutil.rmtree(apk1dir)
1848 shutil.rmtree(apk2dir)
1850 # If we get here, it seems like they're the same!
1854 def find_command(command):
1855 '''find the full path of a command, or None if it can't be found in the PATH'''
1858 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1860 fpath, fname = os.path.split(command)
1865 for path in os.environ["PATH"].split(os.pathsep):
1866 path = path.strip('"')
1867 exe_file = os.path.join(path, command)
1868 if is_exe(exe_file):
1875 '''generate a random password for when generating keys'''
1876 h = hashlib.sha256()
1877 h.update(os.urandom(16)) # salt
1878 h.update(bytes(socket.getfqdn()))
1879 return h.digest().encode('base64').strip()
1882 def genkeystore(localconfig):
1883 '''Generate a new key with random passwords and add it to new keystore'''
1884 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1885 keystoredir = os.path.dirname(localconfig['keystore'])
1886 if keystoredir is None or keystoredir == '':
1887 keystoredir = os.path.join(os.getcwd(), keystoredir)
1888 if not os.path.exists(keystoredir):
1889 os.makedirs(keystoredir, mode=0o700)
1891 write_password_file("keystorepass", localconfig['keystorepass'])
1892 write_password_file("keypass", localconfig['keypass'])
1893 p = FDroidPopen(['keytool', '-genkey',
1894 '-keystore', localconfig['keystore'],
1895 '-alias', localconfig['repo_keyalias'],
1896 '-keyalg', 'RSA', '-keysize', '4096',
1897 '-sigalg', 'SHA256withRSA',
1898 '-validity', '10000',
1899 '-storepass:file', config['keystorepassfile'],
1900 '-keypass:file', config['keypassfile'],
1901 '-dname', localconfig['keydname']])
1902 # TODO keypass should be sent via stdin
1903 if p.returncode != 0:
1904 raise BuildException("Failed to generate key", p.output)
1905 os.chmod(localconfig['keystore'], 0o0600)
1906 # now show the lovely key that was just generated
1907 p = FDroidPopen(['keytool', '-list', '-v',
1908 '-keystore', localconfig['keystore'],
1909 '-alias', localconfig['repo_keyalias'],
1910 '-storepass:file', config['keystorepassfile']])
1911 logging.info(p.output.strip() + '\n\n')
1914 def write_to_config(thisconfig, key, value=None):
1915 '''write a key/value to the local config.py'''
1917 origkey = key + '_orig'
1918 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1919 with open('config.py', 'r') as f:
1921 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1922 repl = '\n' + key + ' = "' + value + '"'
1923 data = re.sub(pattern, repl, data)
1924 # if this key is not in the file, append it
1925 if not re.match('\s*' + key + '\s*=\s*"', data):
1927 # make sure the file ends with a carraige return
1928 if not re.match('\n$', data):
1930 with open('config.py', 'w') as f:
1934 def parse_xml(path):
1935 return XMLElementTree.parse(path).getroot()
1938 def string_is_integer(string):
1946 def get_per_app_repos():
1947 '''per-app repos are dirs named with the packageName of a single app'''
1949 # Android packageNames are Java packages, they may contain uppercase or
1950 # lowercase letters ('A' through 'Z'), numbers, and underscores
1951 # ('_'). However, individual package name parts may only start with
1952 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1953 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1956 for root, dirs, files in os.walk(os.getcwd()):
1958 print('checking', root, 'for', d)
1959 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1960 # standard parts of an fdroid repo, so never packageNames
1963 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):