1 # -*- coding: utf-8 -*-
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
35 import xml.etree.ElementTree as XMLElementTree
39 from Queue import Queue
42 from queue import Queue
44 from zipfile import ZipFile
47 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
50 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
59 'sdk_path': "$ANDROID_HOME",
62 'r10e': "$ANDROID_NDK",
64 'build_tools': "23.0.2",
69 'accepted_formats': ['txt', 'yaml'],
70 'sync_from_local_copy_dir': False,
71 'per_app_repos': False,
72 'make_current_version_link': True,
73 'current_version_name_source': 'Name',
74 'update_stats': False,
78 'stats_to_carbon': False,
80 'build_server_always': False,
81 'keystore': 'keystore.jks',
82 'smartcardoptions': [],
88 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
89 'repo_name': "My First FDroid Repo Demo",
90 'repo_icon': "fdroid-icon.png",
91 'repo_description': '''
92 This is a repository of apps to be used with FDroid. Applications in this
93 repository are either official binaries built by the original application
94 developers, or are binaries built from source by the admin of f-droid.org
95 using the tools on https://gitlab.com/u/fdroid.
101 def setup_global_opts(parser):
102 parser.add_argument("-v", "--verbose", action="store_true", default=False,
103 help="Spew out even more information than normal")
104 parser.add_argument("-q", "--quiet", action="store_true", default=False,
105 help="Restrict output to warnings and errors")
108 def fill_config_defaults(thisconfig):
109 for k, v in default_config.items():
110 if k not in thisconfig:
113 # Expand paths (~users and $vars)
114 def expand_path(path):
118 path = os.path.expanduser(path)
119 path = os.path.expandvars(path)
124 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
129 thisconfig[k + '_orig'] = v
131 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
132 if thisconfig['java_paths'] is None:
133 thisconfig['java_paths'] = dict()
134 for d in sorted(glob.glob('/usr/lib/jvm/j*[6-9]*')
135 + glob.glob('/usr/java/jdk1.[6-9]*')
136 + glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
137 + glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')):
138 if os.path.islink(d):
140 j = os.path.basename(d)
141 # the last one found will be the canonical one, so order appropriately
143 r'^1\.([6-9])\.0\.jdk$', # OSX
144 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
145 r'^jdk([6-9])-openjdk$', # Arch
146 r'^java-([6-9])-openjdk$', # Arch
147 r'^java-([6-9])-jdk$', # Arch (oracle)
148 r'^java-1\.([6-9])\.0-.*$', # RedHat
149 r'^java-([6-9])-oracle$', # Debian WebUpd8
150 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
151 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
153 m = re.match(regex, j)
156 osxhome = os.path.join(d, 'Contents', 'Home')
157 if os.path.exists(osxhome):
158 thisconfig['java_paths'][m.group(1)] = osxhome
160 thisconfig['java_paths'][m.group(1)] = d
162 for java_version in ('7', '8', '9'):
163 if java_version not in thisconfig['java_paths']:
165 java_home = thisconfig['java_paths'][java_version]
166 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
167 if os.path.exists(jarsigner):
168 thisconfig['jarsigner'] = jarsigner
169 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
170 break # Java7 is preferred, so quit if found
172 for k in ['ndk_paths', 'java_paths']:
178 thisconfig[k][k2] = exp
179 thisconfig[k][k2 + '_orig'] = v
182 def regsub_file(pattern, repl, path):
183 with open(path, 'r') as f:
185 text = re.sub(pattern, repl, text)
186 with open(path, 'w') as f:
190 def read_config(opts, config_file='config.py'):
191 """Read the repository config
193 The config is read from config_file, which is in the current directory when
194 any of the repo management commands are used.
196 global config, options, env, orig_path
198 if config is not None:
200 if not os.path.isfile(config_file):
201 logging.critical("Missing config file - is this a repo directory?")
208 logging.debug("Reading %s" % config_file)
209 execfile(config_file, config)
211 # smartcardoptions must be a list since its command line args for Popen
212 if 'smartcardoptions' in config:
213 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
214 elif 'keystore' in config and config['keystore'] == 'NONE':
215 # keystore='NONE' means use smartcard, these are required defaults
216 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
217 'SunPKCS11-OpenSC', '-providerClass',
218 'sun.security.pkcs11.SunPKCS11',
219 '-providerArg', 'opensc-fdroid.cfg']
221 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
222 st = os.stat(config_file)
223 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
224 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
226 fill_config_defaults(config)
228 # There is no standard, so just set up the most common environment
231 orig_path = env['PATH']
232 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
233 env[n] = config['sdk_path']
235 for k, v in config['java_paths'].items():
236 env['JAVA%s_HOME' % k] = v
238 for k in ["keystorepass", "keypass"]:
240 write_password_file(k)
242 for k in ["repo_description", "archive_description"]:
244 config[k] = clean_description(config[k])
246 if 'serverwebroot' in config:
247 if isinstance(config['serverwebroot'], basestring):
248 roots = [config['serverwebroot']]
249 elif all(isinstance(item, basestring) for item in config['serverwebroot']):
250 roots = config['serverwebroot']
252 raise TypeError('only accepts strings, lists, and tuples')
254 for rootstr in roots:
255 # since this is used with rsync, where trailing slashes have
256 # meaning, ensure there is always a trailing slash
257 if rootstr[-1] != '/':
259 rootlist.append(rootstr.replace('//', '/'))
260 config['serverwebroot'] = rootlist
265 def find_sdk_tools_cmd(cmd):
266 '''find a working path to a tool from the Android SDK'''
269 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
270 # try to find a working path to this command, in all the recent possible paths
271 if 'build_tools' in config:
272 build_tools = os.path.join(config['sdk_path'], 'build-tools')
273 # if 'build_tools' was manually set and exists, check only that one
274 configed_build_tools = os.path.join(build_tools, config['build_tools'])
275 if os.path.exists(configed_build_tools):
276 tooldirs.append(configed_build_tools)
278 # no configed version, so hunt known paths for it
279 for f in sorted(os.listdir(build_tools), reverse=True):
280 if os.path.isdir(os.path.join(build_tools, f)):
281 tooldirs.append(os.path.join(build_tools, f))
282 tooldirs.append(build_tools)
283 sdk_tools = os.path.join(config['sdk_path'], 'tools')
284 if os.path.exists(sdk_tools):
285 tooldirs.append(sdk_tools)
286 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
287 if os.path.exists(sdk_platform_tools):
288 tooldirs.append(sdk_platform_tools)
289 tooldirs.append('/usr/bin')
291 if os.path.isfile(os.path.join(d, cmd)):
292 return os.path.join(d, cmd)
293 # did not find the command, exit with error message
294 ensure_build_tools_exists(config)
297 def test_sdk_exists(thisconfig):
298 if 'sdk_path' not in thisconfig:
299 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
302 logging.error("'sdk_path' not set in config.py!")
304 if thisconfig['sdk_path'] == default_config['sdk_path']:
305 logging.error('No Android SDK found!')
306 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
307 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
309 if not os.path.exists(thisconfig['sdk_path']):
310 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
312 if not os.path.isdir(thisconfig['sdk_path']):
313 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
315 for d in ['build-tools', 'platform-tools', 'tools']:
316 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
317 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
318 thisconfig['sdk_path'], d))
323 def ensure_build_tools_exists(thisconfig):
324 if not test_sdk_exists(thisconfig):
326 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
327 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
328 if not os.path.isdir(versioned_build_tools):
329 logging.critical('Android Build Tools path "'
330 + versioned_build_tools + '" does not exist!')
334 def write_password_file(pwtype, password=None):
336 writes out passwords to a protected file instead of passing passwords as
337 command line argments
339 filename = '.fdroid.' + pwtype + '.txt'
340 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
342 os.write(fd, config[pwtype])
344 os.write(fd, password)
346 config[pwtype + 'file'] = filename
349 # Given the arguments in the form of multiple appid:[vc] strings, this returns
350 # a dictionary with the set of vercodes specified for each package.
351 def read_pkg_args(args, allow_vercodes=False):
358 if allow_vercodes and ':' in p:
359 package, vercode = p.split(':')
361 package, vercode = p, None
362 if package not in vercodes:
363 vercodes[package] = [vercode] if vercode else []
365 elif vercode and vercode not in vercodes[package]:
366 vercodes[package] += [vercode] if vercode else []
371 # On top of what read_pkg_args does, this returns the whole app metadata, but
372 # limiting the builds list to the builds matching the vercodes specified.
373 def read_app_args(args, allapps, allow_vercodes=False):
375 vercodes = read_pkg_args(args, allow_vercodes)
381 for appid, app in allapps.iteritems():
382 if appid in vercodes:
385 if len(apps) != len(vercodes):
388 logging.critical("No such package: %s" % p)
389 raise FDroidException("Found invalid app ids in arguments")
391 raise FDroidException("No packages specified")
394 for appid, app in apps.iteritems():
398 app.builds = [b for b in app.builds if b.vercode in vc]
399 if len(app.builds) != len(vercodes[appid]):
401 allvcs = [b.vercode for b in app.builds]
402 for v in vercodes[appid]:
404 logging.critical("No such vercode %s for app %s" % (v, appid))
407 raise FDroidException("Found invalid vercodes for some apps")
412 def get_extension(filename):
413 base, ext = os.path.splitext(filename)
416 return base, ext.lower()[1:]
419 def has_extension(filename, ext):
420 _, f_ext = get_extension(filename)
424 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
427 def clean_description(description):
428 'Remove unneeded newlines and spaces from a block of description text'
430 # this is split up by paragraph to make removing the newlines easier
431 for paragraph in re.split(r'\n\n', description):
432 paragraph = re.sub('\r', '', paragraph)
433 paragraph = re.sub('\n', ' ', paragraph)
434 paragraph = re.sub(' {2,}', ' ', paragraph)
435 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
436 returnstring += paragraph + '\n\n'
437 return returnstring.rstrip('\n')
440 def apknameinfo(filename):
441 filename = os.path.basename(filename)
442 m = apk_regex.match(filename)
444 result = (m.group(1), m.group(2))
445 except AttributeError:
446 raise FDroidException("Invalid apk name: %s" % filename)
450 def getapkname(app, build):
451 return "%s_%s.apk" % (app.id, build.vercode)
454 def getsrcname(app, build):
455 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
467 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
470 def getvcs(vcstype, remote, local):
472 return vcs_git(remote, local)
473 if vcstype == 'git-svn':
474 return vcs_gitsvn(remote, local)
476 return vcs_hg(remote, local)
478 return vcs_bzr(remote, local)
479 if vcstype == 'srclib':
480 if local != os.path.join('build', 'srclib', remote):
481 raise VCSException("Error: srclib paths are hard-coded!")
482 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
484 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
485 raise VCSException("Invalid vcs type " + vcstype)
488 def getsrclibvcs(name):
489 if name not in metadata.srclibs:
490 raise VCSException("Missing srclib " + name)
491 return metadata.srclibs[name]['Repo Type']
496 def __init__(self, remote, local):
498 # svn, git-svn and bzr may require auth
500 if self.repotype() in ('git-svn', 'bzr'):
502 if self.repotype == 'git-svn':
503 raise VCSException("Authentication is not supported for git-svn")
504 self.username, remote = remote.split('@')
505 if ':' not in self.username:
506 raise VCSException("Password required with username")
507 self.username, self.password = self.username.split(':')
511 self.clone_failed = False
512 self.refreshed = False
518 # Take the local repository to a clean version of the given revision, which
519 # is specificed in the VCS's native format. Beforehand, the repository can
520 # be dirty, or even non-existent. If the repository does already exist
521 # locally, it will be updated from the origin, but only once in the
522 # lifetime of the vcs object.
523 # None is acceptable for 'rev' if you know you are cloning a clean copy of
524 # the repo - otherwise it must specify a valid revision.
525 def gotorevision(self, rev, refresh=True):
527 if self.clone_failed:
528 raise VCSException("Downloading the repository already failed once, not trying again.")
530 # The .fdroidvcs-id file for a repo tells us what VCS type
531 # and remote that directory was created from, allowing us to drop it
532 # automatically if either of those things changes.
533 fdpath = os.path.join(self.local, '..',
534 '.fdroidvcs-' + os.path.basename(self.local))
535 cdata = self.repotype() + ' ' + self.remote
538 if os.path.exists(self.local):
539 if os.path.exists(fdpath):
540 with open(fdpath, 'r') as f:
541 fsdata = f.read().strip()
546 logging.info("Repository details for %s changed - deleting" % (
550 logging.info("Repository details for %s missing - deleting" % (
553 shutil.rmtree(self.local)
557 self.refreshed = True
560 self.gotorevisionx(rev)
561 except FDroidException as e:
564 # If necessary, write the .fdroidvcs file.
565 if writeback and not self.clone_failed:
566 with open(fdpath, 'w') as f:
572 # Derived classes need to implement this. It's called once basic checking
573 # has been performend.
574 def gotorevisionx(self, rev):
575 raise VCSException("This VCS type doesn't define gotorevisionx")
577 # Initialise and update submodules
578 def initsubmodules(self):
579 raise VCSException('Submodules not supported for this vcs type')
581 # Get a list of all known tags
583 if not self._gettags:
584 raise VCSException('gettags not supported for this vcs type')
586 for tag in self._gettags():
587 if re.match('[-A-Za-z0-9_. /]+$', tag):
591 # Get a list of all the known tags, sorted from newest to oldest
592 def latesttags(self):
593 raise VCSException('latesttags not supported for this vcs type')
595 # Get current commit reference (hash, revision, etc)
597 raise VCSException('getref not supported for this vcs type')
599 # Returns the srclib (name, path) used in setting up the current
610 # If the local directory exists, but is somehow not a git repository, git
611 # will traverse up the directory tree until it finds one that is (i.e.
612 # fdroidserver) and then we'll proceed to destroy it! This is called as
615 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
616 result = p.output.rstrip()
617 if not result.endswith(self.local):
618 raise VCSException('Repository mismatch')
620 def gotorevisionx(self, rev):
621 if not os.path.exists(self.local):
623 p = FDroidPopen(['git', 'clone', self.remote, self.local])
624 if p.returncode != 0:
625 self.clone_failed = True
626 raise VCSException("Git clone failed", p.output)
630 # Discard any working tree changes
631 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
632 'git', 'reset', '--hard'], cwd=self.local, output=False)
633 if p.returncode != 0:
634 raise VCSException("Git reset failed", p.output)
635 # Remove untracked files now, in case they're tracked in the target
636 # revision (it happens!)
637 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
638 'git', 'clean', '-dffx'], cwd=self.local, output=False)
639 if p.returncode != 0:
640 raise VCSException("Git clean failed", p.output)
641 if not self.refreshed:
642 # Get latest commits and tags from remote
643 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
644 if p.returncode != 0:
645 raise VCSException("Git fetch failed", p.output)
646 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
647 if p.returncode != 0:
648 raise VCSException("Git fetch failed", p.output)
649 # Recreate origin/HEAD as git clone would do it, in case it disappeared
650 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
651 if p.returncode != 0:
652 lines = p.output.splitlines()
653 if 'Multiple remote HEAD branches' not in lines[0]:
654 raise VCSException("Git remote set-head failed", p.output)
655 branch = lines[1].split(' ')[-1]
656 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
657 if p2.returncode != 0:
658 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
659 self.refreshed = True
660 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
661 # a github repo. Most of the time this is the same as origin/master.
662 rev = rev or 'origin/HEAD'
663 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
664 if p.returncode != 0:
665 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
666 # Get rid of any uncontrolled files left behind
667 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
668 if p.returncode != 0:
669 raise VCSException("Git clean failed", p.output)
671 def initsubmodules(self):
673 submfile = os.path.join(self.local, '.gitmodules')
674 if not os.path.isfile(submfile):
675 raise VCSException("No git submodules available")
677 # fix submodules not accessible without an account and public key auth
678 with open(submfile, 'r') as f:
679 lines = f.readlines()
680 with open(submfile, 'w') as f:
682 if 'git@github.com' in line:
683 line = line.replace('git@github.com:', 'https://github.com/')
684 if 'git@gitlab.com' in line:
685 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
688 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
689 if p.returncode != 0:
690 raise VCSException("Git submodule sync failed", p.output)
691 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
692 if p.returncode != 0:
693 raise VCSException("Git submodule update failed", p.output)
697 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
698 return p.output.splitlines()
700 tag_format = re.compile(r'.*tag: ([^),]*).*')
702 def latesttags(self):
704 p = FDroidPopen(['git', 'log', '--tags',
705 '--simplify-by-decoration', '--pretty=format:%d'],
706 cwd=self.local, output=False)
708 for line in p.output.splitlines():
709 m = self.tag_format.match(line)
717 class vcs_gitsvn(vcs):
722 # If the local directory exists, but is somehow not a git repository, git
723 # will traverse up the directory tree until it finds one that is (i.e.
724 # fdroidserver) and then we'll proceed to destory it! This is called as
727 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
728 result = p.output.rstrip()
729 if not result.endswith(self.local):
730 raise VCSException('Repository mismatch')
732 def gotorevisionx(self, rev):
733 if not os.path.exists(self.local):
735 gitsvn_args = ['git', 'svn', 'clone']
736 if ';' in self.remote:
737 remote_split = self.remote.split(';')
738 for i in remote_split[1:]:
739 if i.startswith('trunk='):
740 gitsvn_args.extend(['-T', i[6:]])
741 elif i.startswith('tags='):
742 gitsvn_args.extend(['-t', i[5:]])
743 elif i.startswith('branches='):
744 gitsvn_args.extend(['-b', i[9:]])
745 gitsvn_args.extend([remote_split[0], self.local])
746 p = FDroidPopen(gitsvn_args, output=False)
747 if p.returncode != 0:
748 self.clone_failed = True
749 raise VCSException("Git svn clone failed", p.output)
751 gitsvn_args.extend([self.remote, self.local])
752 p = FDroidPopen(gitsvn_args, output=False)
753 if p.returncode != 0:
754 self.clone_failed = True
755 raise VCSException("Git svn clone failed", p.output)
759 # Discard any working tree changes
760 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
761 if p.returncode != 0:
762 raise VCSException("Git reset failed", p.output)
763 # Remove untracked files now, in case they're tracked in the target
764 # revision (it happens!)
765 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
766 if p.returncode != 0:
767 raise VCSException("Git clean failed", p.output)
768 if not self.refreshed:
769 # Get new commits, branches and tags from repo
770 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
771 if p.returncode != 0:
772 raise VCSException("Git svn fetch failed")
773 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
774 if p.returncode != 0:
775 raise VCSException("Git svn rebase failed", p.output)
776 self.refreshed = True
778 rev = rev or 'master'
780 nospaces_rev = rev.replace(' ', '%20')
781 # Try finding a svn tag
782 for treeish in ['origin/', '']:
783 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
784 if p.returncode == 0:
786 if p.returncode != 0:
787 # No tag found, normal svn rev translation
788 # Translate svn rev into git format
789 rev_split = rev.split('/')
792 for treeish in ['origin/', '']:
793 if len(rev_split) > 1:
794 treeish += rev_split[0]
795 svn_rev = rev_split[1]
798 # if no branch is specified, then assume trunk (i.e. 'master' branch):
802 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
804 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
805 git_rev = p.output.rstrip()
807 if p.returncode == 0 and git_rev:
810 if p.returncode != 0 or not git_rev:
811 # Try a plain git checkout as a last resort
812 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
813 if p.returncode != 0:
814 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
816 # Check out the git rev equivalent to the svn rev
817 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
818 if p.returncode != 0:
819 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
821 # Get rid of any uncontrolled files left behind
822 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
823 if p.returncode != 0:
824 raise VCSException("Git clean failed", p.output)
828 for treeish in ['origin/', '']:
829 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
835 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
836 if p.returncode != 0:
838 return p.output.strip()
846 def gotorevisionx(self, rev):
847 if not os.path.exists(self.local):
848 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
849 if p.returncode != 0:
850 self.clone_failed = True
851 raise VCSException("Hg clone failed", p.output)
853 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
854 if p.returncode != 0:
855 raise VCSException("Hg status failed", p.output)
856 for line in p.output.splitlines():
857 if not line.startswith('? '):
858 raise VCSException("Unexpected output from hg status -uS: " + line)
859 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
860 if not self.refreshed:
861 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
862 if p.returncode != 0:
863 raise VCSException("Hg pull failed", p.output)
864 self.refreshed = True
866 rev = rev or 'default'
869 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
870 if p.returncode != 0:
871 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
872 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
873 # Also delete untracked files, we have to enable purge extension for that:
874 if "'purge' is provided by the following extension" in p.output:
875 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
876 myfile.write("\n[extensions]\nhgext.purge=\n")
877 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
878 if p.returncode != 0:
879 raise VCSException("HG purge failed", p.output)
880 elif p.returncode != 0:
881 raise VCSException("HG purge failed", p.output)
884 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
885 return p.output.splitlines()[1:]
893 def gotorevisionx(self, rev):
894 if not os.path.exists(self.local):
895 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
896 if p.returncode != 0:
897 self.clone_failed = True
898 raise VCSException("Bzr branch failed", p.output)
900 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
901 if p.returncode != 0:
902 raise VCSException("Bzr revert failed", p.output)
903 if not self.refreshed:
904 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
905 if p.returncode != 0:
906 raise VCSException("Bzr update failed", p.output)
907 self.refreshed = True
909 revargs = list(['-r', rev] if rev else [])
910 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
911 if p.returncode != 0:
912 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
915 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
916 return [tag.split(' ')[0].strip() for tag in
917 p.output.splitlines()]
920 def unescape_string(string):
923 if string[0] == '"' and string[-1] == '"':
926 return string.replace("\\'", "'")
929 def retrieve_string(app_dir, string, xmlfiles=None):
931 if not string.startswith('@string/'):
932 return unescape_string(string)
937 os.path.join(app_dir, 'res'),
938 os.path.join(app_dir, 'src', 'main', 'res'),
940 for r, d, f in os.walk(res_dir):
941 if os.path.basename(r) == 'values':
942 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
944 name = string[len('@string/'):]
946 def element_content(element):
947 if element.text is None:
949 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
952 for path in xmlfiles:
953 if not os.path.isfile(path):
955 xml = parse_xml(path)
956 element = xml.find('string[@name="' + name + '"]')
957 if element is not None:
958 content = element_content(element)
959 return retrieve_string(app_dir, content, xmlfiles)
964 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
965 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
968 # Return list of existing files that will be used to find the highest vercode
969 def manifest_paths(app_dir, flavours):
971 possible_manifests = \
972 [os.path.join(app_dir, 'AndroidManifest.xml'),
973 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
974 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
975 os.path.join(app_dir, 'build.gradle')]
977 for flavour in flavours:
980 possible_manifests.append(
981 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
983 return [path for path in possible_manifests if os.path.isfile(path)]
986 # Retrieve the package name. Returns the name, or None if not found.
987 def fetch_real_name(app_dir, flavours):
988 for path in manifest_paths(app_dir, flavours):
989 if not has_extension(path, 'xml') or not os.path.isfile(path):
991 logging.debug("fetch_real_name: Checking manifest at " + path)
992 xml = parse_xml(path)
993 app = xml.find('application')
996 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
998 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
999 result = retrieve_string_singleline(app_dir, label)
1001 result = result.strip()
1006 def get_library_references(root_dir):
1008 proppath = os.path.join(root_dir, 'project.properties')
1009 if not os.path.isfile(proppath):
1011 for line in file(proppath):
1012 if not line.startswith('android.library.reference.'):
1014 path = line.split('=')[1].strip()
1015 relpath = os.path.join(root_dir, path)
1016 if not os.path.isdir(relpath):
1018 logging.debug("Found subproject at %s" % path)
1019 libraries.append(path)
1023 def ant_subprojects(root_dir):
1024 subprojects = get_library_references(root_dir)
1025 for subpath in subprojects:
1026 subrelpath = os.path.join(root_dir, subpath)
1027 for p in get_library_references(subrelpath):
1028 relp = os.path.normpath(os.path.join(subpath, p))
1029 if relp not in subprojects:
1030 subprojects.insert(0, relp)
1034 def remove_debuggable_flags(root_dir):
1035 # Remove forced debuggable flags
1036 logging.debug("Removing debuggable flags from %s" % root_dir)
1037 for root, dirs, files in os.walk(root_dir):
1038 if 'AndroidManifest.xml' in files:
1039 regsub_file(r'android:debuggable="[^"]*"',
1041 os.path.join(root, 'AndroidManifest.xml'))
1044 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1045 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1046 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1049 def app_matches_packagename(app, package):
1052 appid = app.UpdateCheckName or app.id
1053 if appid is None or appid == "Ignore":
1055 return appid == package
1058 # Extract some information from the AndroidManifest.xml at the given path.
1059 # Returns (version, vercode, package), any or all of which might be None.
1060 # All values returned are strings.
1061 def parse_androidmanifests(paths, app):
1063 ignoreversions = app.UpdateCheckIgnore
1064 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1067 return (None, None, None)
1075 if not os.path.isfile(path):
1078 logging.debug("Parsing manifest at {0}".format(path))
1079 gradle = has_extension(path, 'gradle')
1085 for line in file(path):
1086 if gradle_comment.match(line):
1088 # Grab first occurence of each to avoid running into
1089 # alternative flavours and builds.
1091 matches = psearch_g(line)
1093 s = matches.group(2)
1094 if app_matches_packagename(app, s):
1097 matches = vnsearch_g(line)
1099 version = matches.group(2)
1101 matches = vcsearch_g(line)
1103 vercode = matches.group(1)
1106 xml = parse_xml(path)
1107 if "package" in xml.attrib:
1108 s = xml.attrib["package"].encode('utf-8')
1109 if app_matches_packagename(app, s):
1111 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1112 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1113 base_dir = os.path.dirname(path)
1114 version = retrieve_string_singleline(base_dir, version)
1115 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1116 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1117 if string_is_integer(a):
1120 logging.warning("Problem with xml at {0}".format(path))
1122 # Remember package name, may be defined separately from version+vercode
1124 package = max_package
1126 logging.debug("..got package={0}, version={1}, vercode={2}"
1127 .format(package, version, vercode))
1129 # Always grab the package name and version name in case they are not
1130 # together with the highest version code
1131 if max_package is None and package is not None:
1132 max_package = package
1133 if max_version is None and version is not None:
1134 max_version = version
1136 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1137 if not ignoresearch or not ignoresearch(version):
1138 if version is not None:
1139 max_version = version
1140 if vercode is not None:
1141 max_vercode = vercode
1142 if package is not None:
1143 max_package = package
1145 max_version = "Ignore"
1147 if max_version is None:
1148 max_version = "Unknown"
1150 if max_package and not is_valid_package_name(max_package):
1151 raise FDroidException("Invalid package name {0}".format(max_package))
1153 return (max_version, max_vercode, max_package)
1156 def is_valid_package_name(name):
1157 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1160 class FDroidException(Exception):
1162 def __init__(self, value, detail=None):
1164 self.detail = detail
1166 def shortened_detail(self):
1167 if len(self.detail) < 16000:
1169 return '[...]\n' + self.detail[-16000:]
1171 def get_wikitext(self):
1172 ret = repr(self.value) + "\n"
1175 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1181 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1185 class VCSException(FDroidException):
1189 class BuildException(FDroidException):
1193 # Get the specified source library.
1194 # Returns the path to it. Normally this is the path to be used when referencing
1195 # it, which may be a subdirectory of the actual project. If you want the base
1196 # directory of the project, pass 'basepath=True'.
1197 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1198 raw=False, prepare=True, preponly=False, refresh=True):
1206 name, ref = spec.split('@')
1208 number, name = name.split(':', 1)
1210 name, subdir = name.split('/', 1)
1212 if name not in metadata.srclibs:
1213 raise VCSException('srclib ' + name + ' not found.')
1215 srclib = metadata.srclibs[name]
1217 sdir = os.path.join(srclib_dir, name)
1220 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1221 vcs.srclib = (name, number, sdir)
1223 vcs.gotorevision(ref, refresh)
1230 libdir = os.path.join(sdir, subdir)
1231 elif srclib["Subdir"]:
1232 for subdir in srclib["Subdir"]:
1233 libdir_candidate = os.path.join(sdir, subdir)
1234 if os.path.exists(libdir_candidate):
1235 libdir = libdir_candidate
1241 remove_signing_keys(sdir)
1242 remove_debuggable_flags(sdir)
1246 if srclib["Prepare"]:
1247 cmd = replace_config_vars(srclib["Prepare"], None)
1249 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1250 if p.returncode != 0:
1251 raise BuildException("Error running prepare command for srclib %s"
1257 return (name, number, libdir)
1259 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1262 # Prepare the source code for a particular build
1263 # 'vcs' - the appropriate vcs object for the application
1264 # 'app' - the application details from the metadata
1265 # 'build' - the build details from the metadata
1266 # 'build_dir' - the path to the build directory, usually
1268 # 'srclib_dir' - the path to the source libraries directory, usually
1270 # 'extlib_dir' - the path to the external libraries directory, usually
1272 # Returns the (root, srclibpaths) where:
1273 # 'root' is the root directory, which may be the same as 'build_dir' or may
1274 # be a subdirectory of it.
1275 # 'srclibpaths' is information on the srclibs being used
1276 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1278 # Optionally, the actual app source can be in a subdirectory
1280 root_dir = os.path.join(build_dir, build.subdir)
1282 root_dir = build_dir
1284 # Get a working copy of the right revision
1285 logging.info("Getting source for revision " + build.commit)
1286 vcs.gotorevision(build.commit, refresh)
1288 # Initialise submodules if required
1289 if build.submodules:
1290 logging.info("Initialising submodules")
1291 vcs.initsubmodules()
1293 # Check that a subdir (if we're using one) exists. This has to happen
1294 # after the checkout, since it might not exist elsewhere
1295 if not os.path.exists(root_dir):
1296 raise BuildException('Missing subdir ' + root_dir)
1298 # Run an init command if one is required
1300 cmd = replace_config_vars(build.init, build)
1301 logging.info("Running 'init' commands in %s" % root_dir)
1303 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1304 if p.returncode != 0:
1305 raise BuildException("Error running init command for %s:%s" %
1306 (app.id, build.version), p.output)
1308 # Apply patches if any
1310 logging.info("Applying patches")
1311 for patch in build.patch:
1312 patch = patch.strip()
1313 logging.info("Applying " + patch)
1314 patch_path = os.path.join('metadata', app.id, patch)
1315 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1316 if p.returncode != 0:
1317 raise BuildException("Failed to apply patch %s" % patch_path)
1319 # Get required source libraries
1322 logging.info("Collecting source libraries")
1323 for lib in build.srclibs:
1324 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1326 for name, number, libpath in srclibpaths:
1327 place_srclib(root_dir, int(number) if number else None, libpath)
1329 basesrclib = vcs.getsrclib()
1330 # If one was used for the main source, add that too.
1332 srclibpaths.append(basesrclib)
1334 # Update the local.properties file
1335 localprops = [os.path.join(build_dir, 'local.properties')]
1337 parts = build.subdir.split(os.sep)
1340 cur = os.path.join(cur, d)
1341 localprops += [os.path.join(cur, 'local.properties')]
1342 for path in localprops:
1344 if os.path.isfile(path):
1345 logging.info("Updating local.properties file at %s" % path)
1346 with open(path, 'r') as f:
1350 logging.info("Creating local.properties file at %s" % path)
1351 # Fix old-fashioned 'sdk-location' by copying
1352 # from sdk.dir, if necessary
1354 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1355 re.S | re.M).group(1)
1356 props += "sdk-location=%s\n" % sdkloc
1358 props += "sdk.dir=%s\n" % config['sdk_path']
1359 props += "sdk-location=%s\n" % config['sdk_path']
1360 ndk_path = build.ndk_path()
1363 props += "ndk.dir=%s\n" % ndk_path
1364 props += "ndk-location=%s\n" % ndk_path
1365 # Add java.encoding if necessary
1367 props += "java.encoding=%s\n" % build.encoding
1368 with open(path, 'w') as f:
1372 if build.build_method() == 'gradle':
1373 flavours = build.gradle
1376 n = build.target.split('-')[1]
1377 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1378 r'compileSdkVersion %s' % n,
1379 os.path.join(root_dir, 'build.gradle'))
1381 # Remove forced debuggable flags
1382 remove_debuggable_flags(root_dir)
1384 # Insert version code and number into the manifest if necessary
1385 if build.forceversion:
1386 logging.info("Changing the version name")
1387 for path in manifest_paths(root_dir, flavours):
1388 if not os.path.isfile(path):
1390 if has_extension(path, 'xml'):
1391 regsub_file(r'android:versionName="[^"]*"',
1392 r'android:versionName="%s"' % build.version,
1394 elif has_extension(path, 'gradle'):
1395 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1396 r"""\1versionName '%s'""" % build.version,
1399 if build.forcevercode:
1400 logging.info("Changing the version code")
1401 for path in manifest_paths(root_dir, flavours):
1402 if not os.path.isfile(path):
1404 if has_extension(path, 'xml'):
1405 regsub_file(r'android:versionCode="[^"]*"',
1406 r'android:versionCode="%s"' % build.vercode,
1408 elif has_extension(path, 'gradle'):
1409 regsub_file(r'versionCode[ =]+[0-9]+',
1410 r'versionCode %s' % build.vercode,
1413 # Delete unwanted files
1415 logging.info("Removing specified files")
1416 for part in getpaths(build_dir, build.rm):
1417 dest = os.path.join(build_dir, part)
1418 logging.info("Removing {0}".format(part))
1419 if os.path.lexists(dest):
1420 if os.path.islink(dest):
1421 FDroidPopen(['unlink', dest], output=False)
1423 FDroidPopen(['rm', '-rf', dest], output=False)
1425 logging.info("...but it didn't exist")
1427 remove_signing_keys(build_dir)
1429 # Add required external libraries
1431 logging.info("Collecting prebuilt libraries")
1432 libsdir = os.path.join(root_dir, 'libs')
1433 if not os.path.exists(libsdir):
1435 for lib in build.extlibs:
1437 logging.info("...installing extlib {0}".format(lib))
1438 libf = os.path.basename(lib)
1439 libsrc = os.path.join(extlib_dir, lib)
1440 if not os.path.exists(libsrc):
1441 raise BuildException("Missing extlib file {0}".format(libsrc))
1442 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1444 # Run a pre-build command if one is required
1446 logging.info("Running 'prebuild' commands in %s" % root_dir)
1448 cmd = replace_config_vars(build.prebuild, build)
1450 # Substitute source library paths into prebuild commands
1451 for name, number, libpath in srclibpaths:
1452 libpath = os.path.relpath(libpath, root_dir)
1453 cmd = cmd.replace('$$' + name + '$$', libpath)
1455 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1456 if p.returncode != 0:
1457 raise BuildException("Error running prebuild command for %s:%s" %
1458 (app.id, build.version), p.output)
1460 # Generate (or update) the ant build file, build.xml...
1461 if build.build_method() == 'ant' and build.update != ['no']:
1462 parms = ['android', 'update', 'lib-project']
1463 lparms = ['android', 'update', 'project']
1466 parms += ['-t', build.target]
1467 lparms += ['-t', build.target]
1469 update_dirs = build.update
1471 update_dirs = ant_subprojects(root_dir) + ['.']
1473 for d in update_dirs:
1474 subdir = os.path.join(root_dir, d)
1476 logging.debug("Updating main project")
1477 cmd = parms + ['-p', d]
1479 logging.debug("Updating subproject %s" % d)
1480 cmd = lparms + ['-p', d]
1481 p = SdkToolsPopen(cmd, cwd=root_dir)
1482 # Check to see whether an error was returned without a proper exit
1483 # code (this is the case for the 'no target set or target invalid'
1485 if p.returncode != 0 or p.output.startswith("Error: "):
1486 raise BuildException("Failed to update project at %s" % d, p.output)
1487 # Clean update dirs via ant
1489 logging.info("Cleaning subproject %s" % d)
1490 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1492 return (root_dir, srclibpaths)
1495 # Extend via globbing the paths from a field and return them as a map from
1496 # original path to resulting paths
1497 def getpaths_map(build_dir, globpaths):
1501 full_path = os.path.join(build_dir, p)
1502 full_path = os.path.normpath(full_path)
1503 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1505 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1509 # Extend via globbing the paths from a field and return them as a set
1510 def getpaths(build_dir, globpaths):
1511 paths_map = getpaths_map(build_dir, globpaths)
1513 for k, v in paths_map.iteritems():
1520 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1526 self.path = os.path.join('stats', 'known_apks.txt')
1528 if os.path.isfile(self.path):
1529 for line in file(self.path):
1530 t = line.rstrip().split(' ')
1532 self.apks[t[0]] = (t[1], None)
1534 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1535 self.changed = False
1537 def writeifchanged(self):
1538 if not self.changed:
1541 if not os.path.exists('stats'):
1545 for apk, app in self.apks.iteritems():
1547 line = apk + ' ' + appid
1549 line += ' ' + time.strftime('%Y-%m-%d', added)
1552 with open(self.path, 'w') as f:
1553 for line in sorted(lst, key=natural_key):
1554 f.write(line + '\n')
1556 # Record an apk (if it's new, otherwise does nothing)
1557 # Returns the date it was added.
1558 def recordapk(self, apk, app):
1559 if apk not in self.apks:
1560 self.apks[apk] = (app, time.gmtime(time.time()))
1562 _, added = self.apks[apk]
1565 # Look up information - given the 'apkname', returns (app id, date added/None).
1566 # Or returns None for an unknown apk.
1567 def getapp(self, apkname):
1568 if apkname in self.apks:
1569 return self.apks[apkname]
1572 # Get the most recent 'num' apps added to the repo, as a list of package ids
1573 # with the most recent first.
1574 def getlatest(self, num):
1576 for apk, app in self.apks.iteritems():
1580 if apps[appid] > added:
1584 sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1585 lst = [app for app, _ in sortedapps]
1590 def isApkDebuggable(apkfile, config):
1591 """Returns True if the given apk file is debuggable
1593 :param apkfile: full path to the apk to check"""
1595 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1597 if p.returncode != 0:
1598 logging.critical("Failed to get apk manifest information")
1600 for line in p.output.splitlines():
1601 if 'android:debuggable' in line and not line.endswith('0x0'):
1611 def SdkToolsPopen(commands, cwd=None, output=True):
1613 if cmd not in config:
1614 config[cmd] = find_sdk_tools_cmd(commands[0])
1615 abscmd = config[cmd]
1617 logging.critical("Could not find '%s' on your system" % cmd)
1619 return FDroidPopen([abscmd] + commands[1:],
1620 cwd=cwd, output=output)
1623 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1625 Run a command and capture the possibly huge output.
1627 :param commands: command and argument list like in subprocess.Popen
1628 :param cwd: optionally specifies a working directory
1629 :returns: A PopenResult.
1635 cwd = os.path.normpath(cwd)
1636 logging.debug("Directory: %s" % cwd)
1637 logging.debug("> %s" % ' '.join(commands))
1639 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1640 result = PopenResult()
1643 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1644 stdout=subprocess.PIPE, stderr=stderr_param)
1645 except OSError as e:
1646 raise BuildException("OSError while trying to execute " +
1647 ' '.join(commands) + ': ' + str(e))
1649 if not stderr_to_stdout and options.verbose:
1650 stderr_queue = Queue()
1651 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1653 while not stderr_reader.eof():
1654 while not stderr_queue.empty():
1655 line = stderr_queue.get()
1656 sys.stderr.write(line)
1661 stdout_queue = Queue()
1662 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1664 # Check the queue for output (until there is no more to get)
1665 while not stdout_reader.eof():
1666 while not stdout_queue.empty():
1667 line = stdout_queue.get()
1668 if output and options.verbose:
1669 # Output directly to console
1670 sys.stderr.write(line)
1672 result.output += line
1676 result.returncode = p.wait()
1680 gradle_comment = re.compile(r'[ ]*//')
1681 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1682 gradle_line_matches = [
1683 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1684 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1685 re.compile(r'.*\.readLine\(.*'),
1689 def remove_signing_keys(build_dir):
1690 for root, dirs, files in os.walk(build_dir):
1691 if 'build.gradle' in files:
1692 path = os.path.join(root, 'build.gradle')
1694 with open(path, "r") as o:
1695 lines = o.readlines()
1701 with open(path, "w") as o:
1702 while i < len(lines):
1705 while line.endswith('\\\n'):
1706 line = line.rstrip('\\\n') + lines[i]
1709 if gradle_comment.match(line):
1714 opened += line.count('{')
1715 opened -= line.count('}')
1718 if gradle_signing_configs.match(line):
1723 if any(s.match(line) for s in gradle_line_matches):
1731 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1734 'project.properties',
1736 'default.properties',
1737 'ant.properties', ]:
1738 if propfile in files:
1739 path = os.path.join(root, propfile)
1741 with open(path, "r") as o:
1742 lines = o.readlines()
1746 with open(path, "w") as o:
1748 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1755 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1758 def reset_env_path():
1759 global env, orig_path
1760 env['PATH'] = orig_path
1763 def add_to_env_path(path):
1765 paths = env['PATH'].split(os.pathsep)
1769 env['PATH'] = os.pathsep.join(paths)
1772 def replace_config_vars(cmd, build):
1774 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1775 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1776 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1777 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1778 if build is not None:
1779 cmd = cmd.replace('$$COMMIT$$', build.commit)
1780 cmd = cmd.replace('$$VERSION$$', build.version)
1781 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1785 def place_srclib(root_dir, number, libpath):
1788 relpath = os.path.relpath(libpath, root_dir)
1789 proppath = os.path.join(root_dir, 'project.properties')
1792 if os.path.isfile(proppath):
1793 with open(proppath, "r") as o:
1794 lines = o.readlines()
1796 with open(proppath, "w") as o:
1799 if line.startswith('android.library.reference.%d=' % number):
1800 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1805 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1807 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1810 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1811 """Verify that two apks are the same
1813 One of the inputs is signed, the other is unsigned. The signature metadata
1814 is transferred from the signed to the unsigned apk, and then jarsigner is
1815 used to verify that the signature from the signed apk is also varlid for
1817 :param signed_apk: Path to a signed apk file
1818 :param unsigned_apk: Path to an unsigned apk file expected to match it
1819 :param tmp_dir: Path to directory for temporary files
1820 :returns: None if the verification is successful, otherwise a string
1821 describing what went wrong.
1823 with ZipFile(signed_apk) as signed_apk_as_zip:
1824 meta_inf_files = ['META-INF/MANIFEST.MF']
1825 for f in signed_apk_as_zip.namelist():
1826 if apk_sigfile.match(f):
1827 meta_inf_files.append(f)
1828 if len(meta_inf_files) < 3:
1829 return "Signature files missing from {0}".format(signed_apk)
1830 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1831 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1832 for meta_inf_file in meta_inf_files:
1833 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1835 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1836 logging.info("...NOT verified - {0}".format(signed_apk))
1837 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1838 logging.info("...successfully verified")
1841 apk_badchars = re.compile('''[/ :;'"]''')
1844 def compare_apks(apk1, apk2, tmp_dir):
1847 Returns None if the apk content is the same (apart from the signing key),
1848 otherwise a string describing what's different, or what went wrong when
1849 trying to do the comparison.
1852 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1853 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1854 for d in [apk1dir, apk2dir]:
1855 if os.path.exists(d):
1858 os.mkdir(os.path.join(d, 'jar-xf'))
1860 if subprocess.call(['jar', 'xf',
1861 os.path.abspath(apk1)],
1862 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1863 return("Failed to unpack " + apk1)
1864 if subprocess.call(['jar', 'xf',
1865 os.path.abspath(apk2)],
1866 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1867 return("Failed to unpack " + apk2)
1869 # try to find apktool in the path, if it hasn't been manually configed
1870 if 'apktool' not in config:
1871 tmp = find_command('apktool')
1873 config['apktool'] = tmp
1874 if 'apktool' in config:
1875 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1877 return("Failed to unpack " + apk1)
1878 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1880 return("Failed to unpack " + apk2)
1882 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1883 lines = p.output.splitlines()
1884 if len(lines) != 1 or 'META-INF' not in lines[0]:
1885 meld = find_command('meld')
1886 if meld is not None:
1887 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1888 return("Unexpected diff output - " + p.output)
1890 # since everything verifies, delete the comparison to keep cruft down
1891 shutil.rmtree(apk1dir)
1892 shutil.rmtree(apk2dir)
1894 # If we get here, it seems like they're the same!
1898 def find_command(command):
1899 '''find the full path of a command, or None if it can't be found in the PATH'''
1902 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1904 fpath, fname = os.path.split(command)
1909 for path in os.environ["PATH"].split(os.pathsep):
1910 path = path.strip('"')
1911 exe_file = os.path.join(path, command)
1912 if is_exe(exe_file):
1919 '''generate a random password for when generating keys'''
1920 h = hashlib.sha256()
1921 h.update(os.urandom(16)) # salt
1922 h.update(bytes(socket.getfqdn()))
1923 return h.digest().encode('base64').strip()
1926 def genkeystore(localconfig):
1927 '''Generate a new key with random passwords and add it to new keystore'''
1928 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1929 keystoredir = os.path.dirname(localconfig['keystore'])
1930 if keystoredir is None or keystoredir == '':
1931 keystoredir = os.path.join(os.getcwd(), keystoredir)
1932 if not os.path.exists(keystoredir):
1933 os.makedirs(keystoredir, mode=0o700)
1935 write_password_file("keystorepass", localconfig['keystorepass'])
1936 write_password_file("keypass", localconfig['keypass'])
1937 p = FDroidPopen([config['keytool'], '-genkey',
1938 '-keystore', localconfig['keystore'],
1939 '-alias', localconfig['repo_keyalias'],
1940 '-keyalg', 'RSA', '-keysize', '4096',
1941 '-sigalg', 'SHA256withRSA',
1942 '-validity', '10000',
1943 '-storepass:file', config['keystorepassfile'],
1944 '-keypass:file', config['keypassfile'],
1945 '-dname', localconfig['keydname']])
1946 # TODO keypass should be sent via stdin
1947 if p.returncode != 0:
1948 raise BuildException("Failed to generate key", p.output)
1949 os.chmod(localconfig['keystore'], 0o0600)
1950 # now show the lovely key that was just generated
1951 p = FDroidPopen([config['keytool'], '-list', '-v',
1952 '-keystore', localconfig['keystore'],
1953 '-alias', localconfig['repo_keyalias'],
1954 '-storepass:file', config['keystorepassfile']])
1955 logging.info(p.output.strip() + '\n\n')
1958 def write_to_config(thisconfig, key, value=None):
1959 '''write a key/value to the local config.py'''
1961 origkey = key + '_orig'
1962 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1963 with open('config.py', 'r') as f:
1965 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1966 repl = '\n' + key + ' = "' + value + '"'
1967 data = re.sub(pattern, repl, data)
1968 # if this key is not in the file, append it
1969 if not re.match('\s*' + key + '\s*=\s*"', data):
1971 # make sure the file ends with a carraige return
1972 if not re.match('\n$', data):
1974 with open('config.py', 'w') as f:
1978 def parse_xml(path):
1979 return XMLElementTree.parse(path).getroot()
1982 def string_is_integer(string):
1990 def get_per_app_repos():
1991 '''per-app repos are dirs named with the packageName of a single app'''
1993 # Android packageNames are Java packages, they may contain uppercase or
1994 # lowercase letters ('A' through 'Z'), numbers, and underscores
1995 # ('_'). However, individual package name parts may only start with
1996 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1997 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2000 for root, dirs, files in os.walk(os.getcwd()):
2002 print('checking', root, 'for', d)
2003 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2004 # standard parts of an fdroid repo, so never packageNames
2007 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):