1 # -*- coding: utf-8 -*-
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
36 import xml.etree.ElementTree as XMLElementTree
40 from Queue import Queue
43 from queue import Queue
45 from zipfile import ZipFile
47 import fdroidserver.metadata
48 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
51 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
60 'sdk_path': "$ANDROID_HOME",
63 'r10e': "$ANDROID_NDK",
65 'build_tools': "23.0.2",
70 'accepted_formats': ['txt', 'yaml'],
71 'sync_from_local_copy_dir': False,
72 'per_app_repos': False,
73 'make_current_version_link': True,
74 'current_version_name_source': 'Name',
75 'update_stats': False,
79 'stats_to_carbon': False,
81 'build_server_always': False,
82 'keystore': 'keystore.jks',
83 'smartcardoptions': [],
89 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
90 'repo_name': "My First FDroid Repo Demo",
91 'repo_icon': "fdroid-icon.png",
92 'repo_description': '''
93 This is a repository of apps to be used with FDroid. Applications in this
94 repository are either official binaries built by the original application
95 developers, or are binaries built from source by the admin of f-droid.org
96 using the tools on https://gitlab.com/u/fdroid.
102 def setup_global_opts(parser):
103 parser.add_argument("-v", "--verbose", action="store_true", default=False,
104 help="Spew out even more information than normal")
105 parser.add_argument("-q", "--quiet", action="store_true", default=False,
106 help="Restrict output to warnings and errors")
109 def fill_config_defaults(thisconfig):
110 for k, v in default_config.items():
111 if k not in thisconfig:
114 # Expand paths (~users and $vars)
115 def expand_path(path):
119 path = os.path.expanduser(path)
120 path = os.path.expandvars(path)
125 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
130 thisconfig[k + '_orig'] = v
132 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
133 if thisconfig['java_paths'] is None:
134 thisconfig['java_paths'] = dict()
135 for d in sorted(glob.glob('/usr/lib/jvm/j*[6-9]*')
136 + glob.glob('/usr/java/jdk1.[6-9]*')
137 + glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
138 + glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')):
139 if os.path.islink(d):
141 j = os.path.basename(d)
142 # the last one found will be the canonical one, so order appropriately
144 r'^1\.([6-9])\.0\.jdk$', # OSX
145 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
146 r'^jdk([6-9])-openjdk$', # Arch
147 r'^java-([6-9])-openjdk$', # Arch
148 r'^java-([6-9])-jdk$', # Arch (oracle)
149 r'^java-1\.([6-9])\.0-.*$', # RedHat
150 r'^java-([6-9])-oracle$', # Debian WebUpd8
151 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
152 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
154 m = re.match(regex, j)
157 osxhome = os.path.join(d, 'Contents', 'Home')
158 if os.path.exists(osxhome):
159 thisconfig['java_paths'][m.group(1)] = osxhome
161 thisconfig['java_paths'][m.group(1)] = d
163 for java_version in ('7', '8', '9'):
164 if java_version not in thisconfig['java_paths']:
166 java_home = thisconfig['java_paths'][java_version]
167 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
168 if os.path.exists(jarsigner):
169 thisconfig['jarsigner'] = jarsigner
170 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
171 break # Java7 is preferred, so quit if found
173 for k in ['ndk_paths', 'java_paths']:
179 thisconfig[k][k2] = exp
180 thisconfig[k][k2 + '_orig'] = v
183 def regsub_file(pattern, repl, path):
184 with open(path, 'r') as f:
186 text = re.sub(pattern, repl, text)
187 with open(path, 'w') as f:
191 def read_config(opts, config_file='config.py'):
192 """Read the repository config
194 The config is read from config_file, which is in the current directory when
195 any of the repo management commands are used.
197 global config, options, env, orig_path
199 if config is not None:
201 if not os.path.isfile(config_file):
202 logging.critical("Missing config file - is this a repo directory?")
209 logging.debug("Reading %s" % config_file)
210 with io.open(config_file, "rb") as f:
211 code = compile(f.read(), config_file, 'exec')
212 exec(code, None, config)
214 # smartcardoptions must be a list since its command line args for Popen
215 if 'smartcardoptions' in config:
216 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
217 elif 'keystore' in config and config['keystore'] == 'NONE':
218 # keystore='NONE' means use smartcard, these are required defaults
219 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
220 'SunPKCS11-OpenSC', '-providerClass',
221 'sun.security.pkcs11.SunPKCS11',
222 '-providerArg', 'opensc-fdroid.cfg']
224 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
225 st = os.stat(config_file)
226 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
227 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
229 fill_config_defaults(config)
231 # There is no standard, so just set up the most common environment
234 orig_path = env['PATH']
235 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
236 env[n] = config['sdk_path']
238 for k, v in config['java_paths'].items():
239 env['JAVA%s_HOME' % k] = v
241 for k in ["keystorepass", "keypass"]:
243 write_password_file(k)
245 for k in ["repo_description", "archive_description"]:
247 config[k] = clean_description(config[k])
249 if 'serverwebroot' in config:
250 if isinstance(config['serverwebroot'], str):
251 roots = [config['serverwebroot']]
252 elif all(isinstance(item, str) for item in config['serverwebroot']):
253 roots = config['serverwebroot']
255 raise TypeError('only accepts strings, lists, and tuples')
257 for rootstr in roots:
258 # since this is used with rsync, where trailing slashes have
259 # meaning, ensure there is always a trailing slash
260 if rootstr[-1] != '/':
262 rootlist.append(rootstr.replace('//', '/'))
263 config['serverwebroot'] = rootlist
268 def find_sdk_tools_cmd(cmd):
269 '''find a working path to a tool from the Android SDK'''
272 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
273 # try to find a working path to this command, in all the recent possible paths
274 if 'build_tools' in config:
275 build_tools = os.path.join(config['sdk_path'], 'build-tools')
276 # if 'build_tools' was manually set and exists, check only that one
277 configed_build_tools = os.path.join(build_tools, config['build_tools'])
278 if os.path.exists(configed_build_tools):
279 tooldirs.append(configed_build_tools)
281 # no configed version, so hunt known paths for it
282 for f in sorted(os.listdir(build_tools), reverse=True):
283 if os.path.isdir(os.path.join(build_tools, f)):
284 tooldirs.append(os.path.join(build_tools, f))
285 tooldirs.append(build_tools)
286 sdk_tools = os.path.join(config['sdk_path'], 'tools')
287 if os.path.exists(sdk_tools):
288 tooldirs.append(sdk_tools)
289 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
290 if os.path.exists(sdk_platform_tools):
291 tooldirs.append(sdk_platform_tools)
292 tooldirs.append('/usr/bin')
294 if os.path.isfile(os.path.join(d, cmd)):
295 return os.path.join(d, cmd)
296 # did not find the command, exit with error message
297 ensure_build_tools_exists(config)
300 def test_sdk_exists(thisconfig):
301 if 'sdk_path' not in thisconfig:
302 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
305 logging.error("'sdk_path' not set in config.py!")
307 if thisconfig['sdk_path'] == default_config['sdk_path']:
308 logging.error('No Android SDK found!')
309 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
310 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
312 if not os.path.exists(thisconfig['sdk_path']):
313 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
315 if not os.path.isdir(thisconfig['sdk_path']):
316 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
318 for d in ['build-tools', 'platform-tools', 'tools']:
319 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
320 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
321 thisconfig['sdk_path'], d))
326 def ensure_build_tools_exists(thisconfig):
327 if not test_sdk_exists(thisconfig):
329 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
330 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
331 if not os.path.isdir(versioned_build_tools):
332 logging.critical('Android Build Tools path "'
333 + versioned_build_tools + '" does not exist!')
337 def write_password_file(pwtype, password=None):
339 writes out passwords to a protected file instead of passing passwords as
340 command line argments
342 filename = '.fdroid.' + pwtype + '.txt'
343 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
345 os.write(fd, config[pwtype])
347 os.write(fd, password)
349 config[pwtype + 'file'] = filename
352 # Given the arguments in the form of multiple appid:[vc] strings, this returns
353 # a dictionary with the set of vercodes specified for each package.
354 def read_pkg_args(args, allow_vercodes=False):
361 if allow_vercodes and ':' in p:
362 package, vercode = p.split(':')
364 package, vercode = p, None
365 if package not in vercodes:
366 vercodes[package] = [vercode] if vercode else []
368 elif vercode and vercode not in vercodes[package]:
369 vercodes[package] += [vercode] if vercode else []
374 # On top of what read_pkg_args does, this returns the whole app metadata, but
375 # limiting the builds list to the builds matching the vercodes specified.
376 def read_app_args(args, allapps, allow_vercodes=False):
378 vercodes = read_pkg_args(args, allow_vercodes)
384 for appid, app in allapps.items():
385 if appid in vercodes:
388 if len(apps) != len(vercodes):
391 logging.critical("No such package: %s" % p)
392 raise FDroidException("Found invalid app ids in arguments")
394 raise FDroidException("No packages specified")
397 for appid, app in apps.items():
401 app.builds = [b for b in app.builds if b.vercode in vc]
402 if len(app.builds) != len(vercodes[appid]):
404 allvcs = [b.vercode for b in app.builds]
405 for v in vercodes[appid]:
407 logging.critical("No such vercode %s for app %s" % (v, appid))
410 raise FDroidException("Found invalid vercodes for some apps")
415 def get_extension(filename):
416 base, ext = os.path.splitext(filename)
419 return base, ext.lower()[1:]
422 def has_extension(filename, ext):
423 _, f_ext = get_extension(filename)
427 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
430 def clean_description(description):
431 'Remove unneeded newlines and spaces from a block of description text'
433 # this is split up by paragraph to make removing the newlines easier
434 for paragraph in re.split(r'\n\n', description):
435 paragraph = re.sub('\r', '', paragraph)
436 paragraph = re.sub('\n', ' ', paragraph)
437 paragraph = re.sub(' {2,}', ' ', paragraph)
438 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
439 returnstring += paragraph + '\n\n'
440 return returnstring.rstrip('\n')
443 def apknameinfo(filename):
444 filename = os.path.basename(filename)
445 m = apk_regex.match(filename)
447 result = (m.group(1), m.group(2))
448 except AttributeError:
449 raise FDroidException("Invalid apk name: %s" % filename)
453 def getapkname(app, build):
454 return "%s_%s.apk" % (app.id, build.vercode)
457 def getsrcname(app, build):
458 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
470 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
473 def getvcs(vcstype, remote, local):
475 return vcs_git(remote, local)
476 if vcstype == 'git-svn':
477 return vcs_gitsvn(remote, local)
479 return vcs_hg(remote, local)
481 return vcs_bzr(remote, local)
482 if vcstype == 'srclib':
483 if local != os.path.join('build', 'srclib', remote):
484 raise VCSException("Error: srclib paths are hard-coded!")
485 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
487 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
488 raise VCSException("Invalid vcs type " + vcstype)
491 def getsrclibvcs(name):
492 if name not in fdroidserver.metadata.srclibs:
493 raise VCSException("Missing srclib " + name)
494 return fdroidserver.metadata.srclibs[name]['Repo Type']
499 def __init__(self, remote, local):
501 # svn, git-svn and bzr may require auth
503 if self.repotype() in ('git-svn', 'bzr'):
505 if self.repotype == 'git-svn':
506 raise VCSException("Authentication is not supported for git-svn")
507 self.username, remote = remote.split('@')
508 if ':' not in self.username:
509 raise VCSException("Password required with username")
510 self.username, self.password = self.username.split(':')
514 self.clone_failed = False
515 self.refreshed = False
521 # Take the local repository to a clean version of the given revision, which
522 # is specificed in the VCS's native format. Beforehand, the repository can
523 # be dirty, or even non-existent. If the repository does already exist
524 # locally, it will be updated from the origin, but only once in the
525 # lifetime of the vcs object.
526 # None is acceptable for 'rev' if you know you are cloning a clean copy of
527 # the repo - otherwise it must specify a valid revision.
528 def gotorevision(self, rev, refresh=True):
530 if self.clone_failed:
531 raise VCSException("Downloading the repository already failed once, not trying again.")
533 # The .fdroidvcs-id file for a repo tells us what VCS type
534 # and remote that directory was created from, allowing us to drop it
535 # automatically if either of those things changes.
536 fdpath = os.path.join(self.local, '..',
537 '.fdroidvcs-' + os.path.basename(self.local))
538 cdata = self.repotype() + ' ' + self.remote
541 if os.path.exists(self.local):
542 if os.path.exists(fdpath):
543 with open(fdpath, 'r') as f:
544 fsdata = f.read().strip()
549 logging.info("Repository details for %s changed - deleting" % (
553 logging.info("Repository details for %s missing - deleting" % (
556 shutil.rmtree(self.local)
560 self.refreshed = True
563 self.gotorevisionx(rev)
564 except FDroidException as e:
567 # If necessary, write the .fdroidvcs file.
568 if writeback and not self.clone_failed:
569 with open(fdpath, 'w') as f:
575 # Derived classes need to implement this. It's called once basic checking
576 # has been performend.
577 def gotorevisionx(self, rev):
578 raise VCSException("This VCS type doesn't define gotorevisionx")
580 # Initialise and update submodules
581 def initsubmodules(self):
582 raise VCSException('Submodules not supported for this vcs type')
584 # Get a list of all known tags
586 if not self._gettags:
587 raise VCSException('gettags not supported for this vcs type')
589 for tag in self._gettags():
590 if re.match('[-A-Za-z0-9_. /]+$', tag):
594 def latesttags(self, tags, number):
595 """Get the most recent tags in a given list.
597 :param tags: a list of tags
598 :param number: the number to return
599 :returns: A list containing the most recent tags in the provided
600 list, up to the maximum number given.
602 raise VCSException('latesttags not supported for this vcs type')
604 # Get current commit reference (hash, revision, etc)
606 raise VCSException('getref not supported for this vcs type')
608 # Returns the srclib (name, path) used in setting up the current
619 # If the local directory exists, but is somehow not a git repository, git
620 # will traverse up the directory tree until it finds one that is (i.e.
621 # fdroidserver) and then we'll proceed to destroy it! This is called as
624 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
625 result = p.output.rstrip()
626 if not result.endswith(self.local):
627 raise VCSException('Repository mismatch')
629 def gotorevisionx(self, rev):
630 if not os.path.exists(self.local):
632 p = FDroidPopen(['git', 'clone', self.remote, self.local])
633 if p.returncode != 0:
634 self.clone_failed = True
635 raise VCSException("Git clone failed", p.output)
639 # Discard any working tree changes
640 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
641 'git', 'reset', '--hard'], cwd=self.local, output=False)
642 if p.returncode != 0:
643 raise VCSException("Git reset failed", p.output)
644 # Remove untracked files now, in case they're tracked in the target
645 # revision (it happens!)
646 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
647 'git', 'clean', '-dffx'], cwd=self.local, output=False)
648 if p.returncode != 0:
649 raise VCSException("Git clean failed", p.output)
650 if not self.refreshed:
651 # Get latest commits and tags from remote
652 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
653 if p.returncode != 0:
654 raise VCSException("Git fetch failed", p.output)
655 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
656 if p.returncode != 0:
657 raise VCSException("Git fetch failed", p.output)
658 # Recreate origin/HEAD as git clone would do it, in case it disappeared
659 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
660 if p.returncode != 0:
661 lines = p.output.splitlines()
662 if 'Multiple remote HEAD branches' not in lines[0]:
663 raise VCSException("Git remote set-head failed", p.output)
664 branch = lines[1].split(' ')[-1]
665 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
666 if p2.returncode != 0:
667 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
668 self.refreshed = True
669 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
670 # a github repo. Most of the time this is the same as origin/master.
671 rev = rev or 'origin/HEAD'
672 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
673 if p.returncode != 0:
674 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
675 # Get rid of any uncontrolled files left behind
676 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
677 if p.returncode != 0:
678 raise VCSException("Git clean failed", p.output)
680 def initsubmodules(self):
682 submfile = os.path.join(self.local, '.gitmodules')
683 if not os.path.isfile(submfile):
684 raise VCSException("No git submodules available")
686 # fix submodules not accessible without an account and public key auth
687 with open(submfile, 'r') as f:
688 lines = f.readlines()
689 with open(submfile, 'w') as f:
691 if 'git@github.com' in line:
692 line = line.replace('git@github.com:', 'https://github.com/')
693 if 'git@gitlab.com' in line:
694 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
697 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
698 if p.returncode != 0:
699 raise VCSException("Git submodule sync failed", p.output)
700 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
701 if p.returncode != 0:
702 raise VCSException("Git submodule update failed", p.output)
706 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
707 return p.output.splitlines()
709 def latesttags(self, tags, number):
714 ['git', 'show', '--format=format:%ct', '-s', tag],
715 cwd=self.local, output=False)
716 # Timestamp is on the last line. For a normal tag, it's the only
717 # line, but for annotated tags, the rest of the info precedes it.
718 ts = int(p.output.splitlines()[-1])
721 for _, t in sorted(tl)[-number:]:
726 class vcs_gitsvn(vcs):
731 # If the local directory exists, but is somehow not a git repository, git
732 # will traverse up the directory tree until it finds one that is (i.e.
733 # fdroidserver) and then we'll proceed to destory it! This is called as
736 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
737 result = p.output.rstrip()
738 if not result.endswith(self.local):
739 raise VCSException('Repository mismatch')
741 def gotorevisionx(self, rev):
742 if not os.path.exists(self.local):
744 gitsvn_args = ['git', 'svn', 'clone']
745 if ';' in self.remote:
746 remote_split = self.remote.split(';')
747 for i in remote_split[1:]:
748 if i.startswith('trunk='):
749 gitsvn_args.extend(['-T', i[6:]])
750 elif i.startswith('tags='):
751 gitsvn_args.extend(['-t', i[5:]])
752 elif i.startswith('branches='):
753 gitsvn_args.extend(['-b', i[9:]])
754 gitsvn_args.extend([remote_split[0], self.local])
755 p = FDroidPopen(gitsvn_args, output=False)
756 if p.returncode != 0:
757 self.clone_failed = True
758 raise VCSException("Git svn clone failed", p.output)
760 gitsvn_args.extend([self.remote, self.local])
761 p = FDroidPopen(gitsvn_args, output=False)
762 if p.returncode != 0:
763 self.clone_failed = True
764 raise VCSException("Git svn clone failed", p.output)
768 # Discard any working tree changes
769 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
770 if p.returncode != 0:
771 raise VCSException("Git reset failed", p.output)
772 # Remove untracked files now, in case they're tracked in the target
773 # revision (it happens!)
774 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
775 if p.returncode != 0:
776 raise VCSException("Git clean failed", p.output)
777 if not self.refreshed:
778 # Get new commits, branches and tags from repo
779 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
780 if p.returncode != 0:
781 raise VCSException("Git svn fetch failed")
782 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
783 if p.returncode != 0:
784 raise VCSException("Git svn rebase failed", p.output)
785 self.refreshed = True
787 rev = rev or 'master'
789 nospaces_rev = rev.replace(' ', '%20')
790 # Try finding a svn tag
791 for treeish in ['origin/', '']:
792 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
793 if p.returncode == 0:
795 if p.returncode != 0:
796 # No tag found, normal svn rev translation
797 # Translate svn rev into git format
798 rev_split = rev.split('/')
801 for treeish in ['origin/', '']:
802 if len(rev_split) > 1:
803 treeish += rev_split[0]
804 svn_rev = rev_split[1]
807 # if no branch is specified, then assume trunk (i.e. 'master' branch):
811 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
813 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
814 git_rev = p.output.rstrip()
816 if p.returncode == 0 and git_rev:
819 if p.returncode != 0 or not git_rev:
820 # Try a plain git checkout as a last resort
821 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
822 if p.returncode != 0:
823 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
825 # Check out the git rev equivalent to the svn rev
826 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
827 if p.returncode != 0:
828 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
830 # Get rid of any uncontrolled files left behind
831 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
832 if p.returncode != 0:
833 raise VCSException("Git clean failed", p.output)
837 for treeish in ['origin/', '']:
838 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
844 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
845 if p.returncode != 0:
847 return p.output.strip()
855 def gotorevisionx(self, rev):
856 if not os.path.exists(self.local):
857 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
858 if p.returncode != 0:
859 self.clone_failed = True
860 raise VCSException("Hg clone failed", p.output)
862 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
863 if p.returncode != 0:
864 raise VCSException("Hg status failed", p.output)
865 for line in p.output.splitlines():
866 if not line.startswith('? '):
867 raise VCSException("Unexpected output from hg status -uS: " + line)
868 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
869 if not self.refreshed:
870 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Hg pull failed", p.output)
873 self.refreshed = True
875 rev = rev or 'default'
878 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
879 if p.returncode != 0:
880 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
881 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
882 # Also delete untracked files, we have to enable purge extension for that:
883 if "'purge' is provided by the following extension" in p.output:
884 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
885 myfile.write("\n[extensions]\nhgext.purge=\n")
886 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
887 if p.returncode != 0:
888 raise VCSException("HG purge failed", p.output)
889 elif p.returncode != 0:
890 raise VCSException("HG purge failed", p.output)
893 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
894 return p.output.splitlines()[1:]
902 def gotorevisionx(self, rev):
903 if not os.path.exists(self.local):
904 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
905 if p.returncode != 0:
906 self.clone_failed = True
907 raise VCSException("Bzr branch failed", p.output)
909 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
910 if p.returncode != 0:
911 raise VCSException("Bzr revert failed", p.output)
912 if not self.refreshed:
913 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
914 if p.returncode != 0:
915 raise VCSException("Bzr update failed", p.output)
916 self.refreshed = True
918 revargs = list(['-r', rev] if rev else [])
919 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
920 if p.returncode != 0:
921 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
924 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
925 return [tag.split(' ')[0].strip() for tag in
926 p.output.splitlines()]
929 def unescape_string(string):
932 if string[0] == '"' and string[-1] == '"':
935 return string.replace("\\'", "'")
938 def retrieve_string(app_dir, string, xmlfiles=None):
940 if not string.startswith('@string/'):
941 return unescape_string(string)
946 os.path.join(app_dir, 'res'),
947 os.path.join(app_dir, 'src', 'main', 'res'),
949 for r, d, f in os.walk(res_dir):
950 if os.path.basename(r) == 'values':
951 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
953 name = string[len('@string/'):]
955 def element_content(element):
956 if element.text is None:
958 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
961 for path in xmlfiles:
962 if not os.path.isfile(path):
964 xml = parse_xml(path)
965 element = xml.find('string[@name="' + name + '"]')
966 if element is not None:
967 content = element_content(element)
968 return retrieve_string(app_dir, content, xmlfiles)
973 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
974 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
977 # Return list of existing files that will be used to find the highest vercode
978 def manifest_paths(app_dir, flavours):
980 possible_manifests = \
981 [os.path.join(app_dir, 'AndroidManifest.xml'),
982 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
983 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
984 os.path.join(app_dir, 'build.gradle')]
986 for flavour in flavours:
989 possible_manifests.append(
990 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
992 return [path for path in possible_manifests if os.path.isfile(path)]
995 # Retrieve the package name. Returns the name, or None if not found.
996 def fetch_real_name(app_dir, flavours):
997 for path in manifest_paths(app_dir, flavours):
998 if not has_extension(path, 'xml') or not os.path.isfile(path):
1000 logging.debug("fetch_real_name: Checking manifest at " + path)
1001 xml = parse_xml(path)
1002 app = xml.find('application')
1005 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1007 label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
1008 result = retrieve_string_singleline(app_dir, label)
1010 result = result.strip()
1015 def get_library_references(root_dir):
1017 proppath = os.path.join(root_dir, 'project.properties')
1018 if not os.path.isfile(proppath):
1020 for line in file(proppath):
1021 if not line.startswith('android.library.reference.'):
1023 path = line.split('=')[1].strip()
1024 relpath = os.path.join(root_dir, path)
1025 if not os.path.isdir(relpath):
1027 logging.debug("Found subproject at %s" % path)
1028 libraries.append(path)
1032 def ant_subprojects(root_dir):
1033 subprojects = get_library_references(root_dir)
1034 for subpath in subprojects:
1035 subrelpath = os.path.join(root_dir, subpath)
1036 for p in get_library_references(subrelpath):
1037 relp = os.path.normpath(os.path.join(subpath, p))
1038 if relp not in subprojects:
1039 subprojects.insert(0, relp)
1043 def remove_debuggable_flags(root_dir):
1044 # Remove forced debuggable flags
1045 logging.debug("Removing debuggable flags from %s" % root_dir)
1046 for root, dirs, files in os.walk(root_dir):
1047 if 'AndroidManifest.xml' in files:
1048 regsub_file(r'android:debuggable="[^"]*"',
1050 os.path.join(root, 'AndroidManifest.xml'))
1053 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1054 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1055 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1058 def app_matches_packagename(app, package):
1061 appid = app.UpdateCheckName or app.id
1062 if appid is None or appid == "Ignore":
1064 return appid == package
1067 # Extract some information from the AndroidManifest.xml at the given path.
1068 # Returns (version, vercode, package), any or all of which might be None.
1069 # All values returned are strings.
1070 def parse_androidmanifests(paths, app):
1072 ignoreversions = app.UpdateCheckIgnore
1073 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1076 return (None, None, None)
1084 if not os.path.isfile(path):
1087 logging.debug("Parsing manifest at {0}".format(path))
1088 gradle = has_extension(path, 'gradle')
1094 for line in file(path):
1095 if gradle_comment.match(line):
1097 # Grab first occurence of each to avoid running into
1098 # alternative flavours and builds.
1100 matches = psearch_g(line)
1102 s = matches.group(2)
1103 if app_matches_packagename(app, s):
1106 matches = vnsearch_g(line)
1108 version = matches.group(2)
1110 matches = vcsearch_g(line)
1112 vercode = matches.group(1)
1115 xml = parse_xml(path)
1116 if "package" in xml.attrib:
1117 s = xml.attrib["package"].encode('utf-8')
1118 if app_matches_packagename(app, s):
1120 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1121 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1122 base_dir = os.path.dirname(path)
1123 version = retrieve_string_singleline(base_dir, version)
1124 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1125 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1126 if string_is_integer(a):
1129 logging.warning("Problem with xml at {0}".format(path))
1131 # Remember package name, may be defined separately from version+vercode
1133 package = max_package
1135 logging.debug("..got package={0}, version={1}, vercode={2}"
1136 .format(package, version, vercode))
1138 # Always grab the package name and version name in case they are not
1139 # together with the highest version code
1140 if max_package is None and package is not None:
1141 max_package = package
1142 if max_version is None and version is not None:
1143 max_version = version
1145 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1146 if not ignoresearch or not ignoresearch(version):
1147 if version is not None:
1148 max_version = version
1149 if vercode is not None:
1150 max_vercode = vercode
1151 if package is not None:
1152 max_package = package
1154 max_version = "Ignore"
1156 if max_version is None:
1157 max_version = "Unknown"
1159 if max_package and not is_valid_package_name(max_package):
1160 raise FDroidException("Invalid package name {0}".format(max_package))
1162 return (max_version, max_vercode, max_package)
1165 def is_valid_package_name(name):
1166 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1169 class FDroidException(Exception):
1171 def __init__(self, value, detail=None):
1173 self.detail = detail
1175 def shortened_detail(self):
1176 if len(self.detail) < 16000:
1178 return '[...]\n' + self.detail[-16000:]
1180 def get_wikitext(self):
1181 ret = repr(self.value) + "\n"
1184 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1190 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1194 class VCSException(FDroidException):
1198 class BuildException(FDroidException):
1202 # Get the specified source library.
1203 # Returns the path to it. Normally this is the path to be used when referencing
1204 # it, which may be a subdirectory of the actual project. If you want the base
1205 # directory of the project, pass 'basepath=True'.
1206 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1207 raw=False, prepare=True, preponly=False, refresh=True):
1215 name, ref = spec.split('@')
1217 number, name = name.split(':', 1)
1219 name, subdir = name.split('/', 1)
1221 if name not in fdroidserver.metadata.srclibs:
1222 raise VCSException('srclib ' + name + ' not found.')
1224 srclib = fdroidserver.metadata.srclibs[name]
1226 sdir = os.path.join(srclib_dir, name)
1229 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1230 vcs.srclib = (name, number, sdir)
1232 vcs.gotorevision(ref, refresh)
1239 libdir = os.path.join(sdir, subdir)
1240 elif srclib["Subdir"]:
1241 for subdir in srclib["Subdir"]:
1242 libdir_candidate = os.path.join(sdir, subdir)
1243 if os.path.exists(libdir_candidate):
1244 libdir = libdir_candidate
1250 remove_signing_keys(sdir)
1251 remove_debuggable_flags(sdir)
1255 if srclib["Prepare"]:
1256 cmd = replace_config_vars(srclib["Prepare"], None)
1258 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1259 if p.returncode != 0:
1260 raise BuildException("Error running prepare command for srclib %s"
1266 return (name, number, libdir)
1268 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1271 # Prepare the source code for a particular build
1272 # 'vcs' - the appropriate vcs object for the application
1273 # 'app' - the application details from the metadata
1274 # 'build' - the build details from the metadata
1275 # 'build_dir' - the path to the build directory, usually
1277 # 'srclib_dir' - the path to the source libraries directory, usually
1279 # 'extlib_dir' - the path to the external libraries directory, usually
1281 # Returns the (root, srclibpaths) where:
1282 # 'root' is the root directory, which may be the same as 'build_dir' or may
1283 # be a subdirectory of it.
1284 # 'srclibpaths' is information on the srclibs being used
1285 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1287 # Optionally, the actual app source can be in a subdirectory
1289 root_dir = os.path.join(build_dir, build.subdir)
1291 root_dir = build_dir
1293 # Get a working copy of the right revision
1294 logging.info("Getting source for revision " + build.commit)
1295 vcs.gotorevision(build.commit, refresh)
1297 # Initialise submodules if required
1298 if build.submodules:
1299 logging.info("Initialising submodules")
1300 vcs.initsubmodules()
1302 # Check that a subdir (if we're using one) exists. This has to happen
1303 # after the checkout, since it might not exist elsewhere
1304 if not os.path.exists(root_dir):
1305 raise BuildException('Missing subdir ' + root_dir)
1307 # Run an init command if one is required
1309 cmd = replace_config_vars(build.init, build)
1310 logging.info("Running 'init' commands in %s" % root_dir)
1312 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1313 if p.returncode != 0:
1314 raise BuildException("Error running init command for %s:%s" %
1315 (app.id, build.version), p.output)
1317 # Apply patches if any
1319 logging.info("Applying patches")
1320 for patch in build.patch:
1321 patch = patch.strip()
1322 logging.info("Applying " + patch)
1323 patch_path = os.path.join('metadata', app.id, patch)
1324 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1325 if p.returncode != 0:
1326 raise BuildException("Failed to apply patch %s" % patch_path)
1328 # Get required source libraries
1331 logging.info("Collecting source libraries")
1332 for lib in build.srclibs:
1333 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1335 for name, number, libpath in srclibpaths:
1336 place_srclib(root_dir, int(number) if number else None, libpath)
1338 basesrclib = vcs.getsrclib()
1339 # If one was used for the main source, add that too.
1341 srclibpaths.append(basesrclib)
1343 # Update the local.properties file
1344 localprops = [os.path.join(build_dir, 'local.properties')]
1346 parts = build.subdir.split(os.sep)
1349 cur = os.path.join(cur, d)
1350 localprops += [os.path.join(cur, 'local.properties')]
1351 for path in localprops:
1353 if os.path.isfile(path):
1354 logging.info("Updating local.properties file at %s" % path)
1355 with open(path, 'r') as f:
1359 logging.info("Creating local.properties file at %s" % path)
1360 # Fix old-fashioned 'sdk-location' by copying
1361 # from sdk.dir, if necessary
1363 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1364 re.S | re.M).group(1)
1365 props += "sdk-location=%s\n" % sdkloc
1367 props += "sdk.dir=%s\n" % config['sdk_path']
1368 props += "sdk-location=%s\n" % config['sdk_path']
1369 ndk_path = build.ndk_path()
1372 props += "ndk.dir=%s\n" % ndk_path
1373 props += "ndk-location=%s\n" % ndk_path
1374 # Add java.encoding if necessary
1376 props += "java.encoding=%s\n" % build.encoding
1377 with open(path, 'w') as f:
1381 if build.build_method() == 'gradle':
1382 flavours = build.gradle
1385 n = build.target.split('-')[1]
1386 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1387 r'compileSdkVersion %s' % n,
1388 os.path.join(root_dir, 'build.gradle'))
1390 # Remove forced debuggable flags
1391 remove_debuggable_flags(root_dir)
1393 # Insert version code and number into the manifest if necessary
1394 if build.forceversion:
1395 logging.info("Changing the version name")
1396 for path in manifest_paths(root_dir, flavours):
1397 if not os.path.isfile(path):
1399 if has_extension(path, 'xml'):
1400 regsub_file(r'android:versionName="[^"]*"',
1401 r'android:versionName="%s"' % build.version,
1403 elif has_extension(path, 'gradle'):
1404 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1405 r"""\1versionName '%s'""" % build.version,
1408 if build.forcevercode:
1409 logging.info("Changing the version code")
1410 for path in manifest_paths(root_dir, flavours):
1411 if not os.path.isfile(path):
1413 if has_extension(path, 'xml'):
1414 regsub_file(r'android:versionCode="[^"]*"',
1415 r'android:versionCode="%s"' % build.vercode,
1417 elif has_extension(path, 'gradle'):
1418 regsub_file(r'versionCode[ =]+[0-9]+',
1419 r'versionCode %s' % build.vercode,
1422 # Delete unwanted files
1424 logging.info("Removing specified files")
1425 for part in getpaths(build_dir, build.rm):
1426 dest = os.path.join(build_dir, part)
1427 logging.info("Removing {0}".format(part))
1428 if os.path.lexists(dest):
1429 if os.path.islink(dest):
1430 FDroidPopen(['unlink', dest], output=False)
1432 FDroidPopen(['rm', '-rf', dest], output=False)
1434 logging.info("...but it didn't exist")
1436 remove_signing_keys(build_dir)
1438 # Add required external libraries
1440 logging.info("Collecting prebuilt libraries")
1441 libsdir = os.path.join(root_dir, 'libs')
1442 if not os.path.exists(libsdir):
1444 for lib in build.extlibs:
1446 logging.info("...installing extlib {0}".format(lib))
1447 libf = os.path.basename(lib)
1448 libsrc = os.path.join(extlib_dir, lib)
1449 if not os.path.exists(libsrc):
1450 raise BuildException("Missing extlib file {0}".format(libsrc))
1451 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1453 # Run a pre-build command if one is required
1455 logging.info("Running 'prebuild' commands in %s" % root_dir)
1457 cmd = replace_config_vars(build.prebuild, build)
1459 # Substitute source library paths into prebuild commands
1460 for name, number, libpath in srclibpaths:
1461 libpath = os.path.relpath(libpath, root_dir)
1462 cmd = cmd.replace('$$' + name + '$$', libpath)
1464 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1465 if p.returncode != 0:
1466 raise BuildException("Error running prebuild command for %s:%s" %
1467 (app.id, build.version), p.output)
1469 # Generate (or update) the ant build file, build.xml...
1470 if build.build_method() == 'ant' and build.update != ['no']:
1471 parms = ['android', 'update', 'lib-project']
1472 lparms = ['android', 'update', 'project']
1475 parms += ['-t', build.target]
1476 lparms += ['-t', build.target]
1478 update_dirs = build.update
1480 update_dirs = ant_subprojects(root_dir) + ['.']
1482 for d in update_dirs:
1483 subdir = os.path.join(root_dir, d)
1485 logging.debug("Updating main project")
1486 cmd = parms + ['-p', d]
1488 logging.debug("Updating subproject %s" % d)
1489 cmd = lparms + ['-p', d]
1490 p = SdkToolsPopen(cmd, cwd=root_dir)
1491 # Check to see whether an error was returned without a proper exit
1492 # code (this is the case for the 'no target set or target invalid'
1494 if p.returncode != 0 or p.output.startswith("Error: "):
1495 raise BuildException("Failed to update project at %s" % d, p.output)
1496 # Clean update dirs via ant
1498 logging.info("Cleaning subproject %s" % d)
1499 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1501 return (root_dir, srclibpaths)
1504 # Extend via globbing the paths from a field and return them as a map from
1505 # original path to resulting paths
1506 def getpaths_map(build_dir, globpaths):
1510 full_path = os.path.join(build_dir, p)
1511 full_path = os.path.normpath(full_path)
1512 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1514 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1518 # Extend via globbing the paths from a field and return them as a set
1519 def getpaths(build_dir, globpaths):
1520 paths_map = getpaths_map(build_dir, globpaths)
1522 for k, v in paths_map.items():
1529 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1535 self.path = os.path.join('stats', 'known_apks.txt')
1537 if os.path.isfile(self.path):
1538 for line in file(self.path):
1539 t = line.rstrip().split(' ')
1541 self.apks[t[0]] = (t[1], None)
1543 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1544 self.changed = False
1546 def writeifchanged(self):
1547 if not self.changed:
1550 if not os.path.exists('stats'):
1554 for apk, app in self.apks.items():
1556 line = apk + ' ' + appid
1558 line += ' ' + time.strftime('%Y-%m-%d', added)
1561 with open(self.path, 'w') as f:
1562 for line in sorted(lst, key=natural_key):
1563 f.write(line + '\n')
1565 # Record an apk (if it's new, otherwise does nothing)
1566 # Returns the date it was added.
1567 def recordapk(self, apk, app):
1568 if apk not in self.apks:
1569 self.apks[apk] = (app, time.gmtime(time.time()))
1571 _, added = self.apks[apk]
1574 # Look up information - given the 'apkname', returns (app id, date added/None).
1575 # Or returns None for an unknown apk.
1576 def getapp(self, apkname):
1577 if apkname in self.apks:
1578 return self.apks[apkname]
1581 # Get the most recent 'num' apps added to the repo, as a list of package ids
1582 # with the most recent first.
1583 def getlatest(self, num):
1585 for apk, app in self.apks.items():
1589 if apps[appid] > added:
1593 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1594 lst = [app for app, _ in sortedapps]
1599 def isApkDebuggable(apkfile, config):
1600 """Returns True if the given apk file is debuggable
1602 :param apkfile: full path to the apk to check"""
1604 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1606 if p.returncode != 0:
1607 logging.critical("Failed to get apk manifest information")
1609 for line in p.output.splitlines():
1610 if 'android:debuggable' in line and not line.endswith('0x0'):
1620 def SdkToolsPopen(commands, cwd=None, output=True):
1622 if cmd not in config:
1623 config[cmd] = find_sdk_tools_cmd(commands[0])
1624 abscmd = config[cmd]
1626 logging.critical("Could not find '%s' on your system" % cmd)
1628 return FDroidPopen([abscmd] + commands[1:],
1629 cwd=cwd, output=output)
1632 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1634 Run a command and capture the possibly huge output.
1636 :param commands: command and argument list like in subprocess.Popen
1637 :param cwd: optionally specifies a working directory
1638 :returns: A PopenResult.
1644 cwd = os.path.normpath(cwd)
1645 logging.debug("Directory: %s" % cwd)
1646 logging.debug("> %s" % ' '.join(commands))
1648 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1649 result = PopenResult()
1652 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1653 stdout=subprocess.PIPE, stderr=stderr_param)
1654 except OSError as e:
1655 raise BuildException("OSError while trying to execute " +
1656 ' '.join(commands) + ': ' + str(e))
1658 if not stderr_to_stdout and options.verbose:
1659 stderr_queue = Queue()
1660 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1662 while not stderr_reader.eof():
1663 while not stderr_queue.empty():
1664 line = stderr_queue.get()
1665 sys.stderr.write(line)
1670 stdout_queue = Queue()
1671 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1673 # Check the queue for output (until there is no more to get)
1674 while not stdout_reader.eof():
1675 while not stdout_queue.empty():
1676 line = stdout_queue.get()
1677 if output and options.verbose:
1678 # Output directly to console
1679 sys.stderr.write(line)
1681 result.output += line
1685 result.returncode = p.wait()
1689 gradle_comment = re.compile(r'[ ]*//')
1690 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1691 gradle_line_matches = [
1692 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1693 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1694 re.compile(r'.*\.readLine\(.*'),
1698 def remove_signing_keys(build_dir):
1699 for root, dirs, files in os.walk(build_dir):
1700 if 'build.gradle' in files:
1701 path = os.path.join(root, 'build.gradle')
1703 with open(path, "r") as o:
1704 lines = o.readlines()
1710 with open(path, "w") as o:
1711 while i < len(lines):
1714 while line.endswith('\\\n'):
1715 line = line.rstrip('\\\n') + lines[i]
1718 if gradle_comment.match(line):
1723 opened += line.count('{')
1724 opened -= line.count('}')
1727 if gradle_signing_configs.match(line):
1732 if any(s.match(line) for s in gradle_line_matches):
1740 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1743 'project.properties',
1745 'default.properties',
1746 'ant.properties', ]:
1747 if propfile in files:
1748 path = os.path.join(root, propfile)
1750 with open(path, "r") as o:
1751 lines = o.readlines()
1755 with open(path, "w") as o:
1757 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1764 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1767 def reset_env_path():
1768 global env, orig_path
1769 env['PATH'] = orig_path
1772 def add_to_env_path(path):
1774 paths = env['PATH'].split(os.pathsep)
1778 env['PATH'] = os.pathsep.join(paths)
1781 def replace_config_vars(cmd, build):
1783 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1784 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1785 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1786 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1787 if build is not None:
1788 cmd = cmd.replace('$$COMMIT$$', build.commit)
1789 cmd = cmd.replace('$$VERSION$$', build.version)
1790 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1794 def place_srclib(root_dir, number, libpath):
1797 relpath = os.path.relpath(libpath, root_dir)
1798 proppath = os.path.join(root_dir, 'project.properties')
1801 if os.path.isfile(proppath):
1802 with open(proppath, "r") as o:
1803 lines = o.readlines()
1805 with open(proppath, "w") as o:
1808 if line.startswith('android.library.reference.%d=' % number):
1809 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1814 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1816 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1819 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1820 """Verify that two apks are the same
1822 One of the inputs is signed, the other is unsigned. The signature metadata
1823 is transferred from the signed to the unsigned apk, and then jarsigner is
1824 used to verify that the signature from the signed apk is also varlid for
1826 :param signed_apk: Path to a signed apk file
1827 :param unsigned_apk: Path to an unsigned apk file expected to match it
1828 :param tmp_dir: Path to directory for temporary files
1829 :returns: None if the verification is successful, otherwise a string
1830 describing what went wrong.
1832 with ZipFile(signed_apk) as signed_apk_as_zip:
1833 meta_inf_files = ['META-INF/MANIFEST.MF']
1834 for f in signed_apk_as_zip.namelist():
1835 if apk_sigfile.match(f):
1836 meta_inf_files.append(f)
1837 if len(meta_inf_files) < 3:
1838 return "Signature files missing from {0}".format(signed_apk)
1839 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1840 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1841 for meta_inf_file in meta_inf_files:
1842 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1844 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1845 logging.info("...NOT verified - {0}".format(signed_apk))
1846 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1847 logging.info("...successfully verified")
1850 apk_badchars = re.compile('''[/ :;'"]''')
1853 def compare_apks(apk1, apk2, tmp_dir):
1856 Returns None if the apk content is the same (apart from the signing key),
1857 otherwise a string describing what's different, or what went wrong when
1858 trying to do the comparison.
1861 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1862 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1863 for d in [apk1dir, apk2dir]:
1864 if os.path.exists(d):
1867 os.mkdir(os.path.join(d, 'jar-xf'))
1869 if subprocess.call(['jar', 'xf',
1870 os.path.abspath(apk1)],
1871 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1872 return("Failed to unpack " + apk1)
1873 if subprocess.call(['jar', 'xf',
1874 os.path.abspath(apk2)],
1875 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1876 return("Failed to unpack " + apk2)
1878 # try to find apktool in the path, if it hasn't been manually configed
1879 if 'apktool' not in config:
1880 tmp = find_command('apktool')
1882 config['apktool'] = tmp
1883 if 'apktool' in config:
1884 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1886 return("Failed to unpack " + apk1)
1887 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1889 return("Failed to unpack " + apk2)
1891 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1892 lines = p.output.splitlines()
1893 if len(lines) != 1 or 'META-INF' not in lines[0]:
1894 meld = find_command('meld')
1895 if meld is not None:
1896 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1897 return("Unexpected diff output - " + p.output)
1899 # since everything verifies, delete the comparison to keep cruft down
1900 shutil.rmtree(apk1dir)
1901 shutil.rmtree(apk2dir)
1903 # If we get here, it seems like they're the same!
1907 def find_command(command):
1908 '''find the full path of a command, or None if it can't be found in the PATH'''
1911 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1913 fpath, fname = os.path.split(command)
1918 for path in os.environ["PATH"].split(os.pathsep):
1919 path = path.strip('"')
1920 exe_file = os.path.join(path, command)
1921 if is_exe(exe_file):
1928 '''generate a random password for when generating keys'''
1929 h = hashlib.sha256()
1930 h.update(os.urandom(16)) # salt
1931 h.update(bytes(socket.getfqdn()))
1932 return h.digest().encode('base64').strip()
1935 def genkeystore(localconfig):
1936 '''Generate a new key with random passwords and add it to new keystore'''
1937 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1938 keystoredir = os.path.dirname(localconfig['keystore'])
1939 if keystoredir is None or keystoredir == '':
1940 keystoredir = os.path.join(os.getcwd(), keystoredir)
1941 if not os.path.exists(keystoredir):
1942 os.makedirs(keystoredir, mode=0o700)
1944 write_password_file("keystorepass", localconfig['keystorepass'])
1945 write_password_file("keypass", localconfig['keypass'])
1946 p = FDroidPopen([config['keytool'], '-genkey',
1947 '-keystore', localconfig['keystore'],
1948 '-alias', localconfig['repo_keyalias'],
1949 '-keyalg', 'RSA', '-keysize', '4096',
1950 '-sigalg', 'SHA256withRSA',
1951 '-validity', '10000',
1952 '-storepass:file', config['keystorepassfile'],
1953 '-keypass:file', config['keypassfile'],
1954 '-dname', localconfig['keydname']])
1955 # TODO keypass should be sent via stdin
1956 if p.returncode != 0:
1957 raise BuildException("Failed to generate key", p.output)
1958 os.chmod(localconfig['keystore'], 0o0600)
1959 # now show the lovely key that was just generated
1960 p = FDroidPopen([config['keytool'], '-list', '-v',
1961 '-keystore', localconfig['keystore'],
1962 '-alias', localconfig['repo_keyalias'],
1963 '-storepass:file', config['keystorepassfile']])
1964 logging.info(p.output.strip() + '\n\n')
1967 def write_to_config(thisconfig, key, value=None):
1968 '''write a key/value to the local config.py'''
1970 origkey = key + '_orig'
1971 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1972 with open('config.py', 'r') as f:
1974 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1975 repl = '\n' + key + ' = "' + value + '"'
1976 data = re.sub(pattern, repl, data)
1977 # if this key is not in the file, append it
1978 if not re.match('\s*' + key + '\s*=\s*"', data):
1980 # make sure the file ends with a carraige return
1981 if not re.match('\n$', data):
1983 with open('config.py', 'w') as f:
1987 def parse_xml(path):
1988 return XMLElementTree.parse(path).getroot()
1991 def string_is_integer(string):
1999 def get_per_app_repos():
2000 '''per-app repos are dirs named with the packageName of a single app'''
2002 # Android packageNames are Java packages, they may contain uppercase or
2003 # lowercase letters ('A' through 'Z'), numbers, and underscores
2004 # ('_'). However, individual package name parts may only start with
2005 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2006 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2009 for root, dirs, files in os.walk(os.getcwd()):
2011 print('checking', root, 'for', d)
2012 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2013 # standard parts of an fdroid repo, so never packageNames
2016 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):