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.
39 import xml.etree.ElementTree as XMLElementTree
41 from binascii import hexlify
42 from datetime import datetime
43 from distutils.version import LooseVersion
44 from queue import Queue
45 from zipfile import ZipFile
47 from pyasn1.codec.der import decoder, encoder
48 from pyasn1_modules import rfc2315
49 from pyasn1.error import PyAsn1Error
51 from distutils.util import strtobool
53 import fdroidserver.metadata
54 from fdroidserver import _
55 from fdroidserver.exception import FDroidException, VCSException, BuildException, VerificationException
56 from .asynchronousfilereader import AsynchronousFileReader
59 # A signature block file with a .DSA, .RSA, or .EC extension
60 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
61 APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
62 STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
64 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
73 'sdk_path': "$ANDROID_HOME",
78 'r12b': "$ANDROID_NDK",
84 'build_tools': "25.0.2",
85 'force_build_tools': False,
90 'accepted_formats': ['txt', 'yml'],
91 'sync_from_local_copy_dir': False,
92 'allow_disabled_algorithms': False,
93 'per_app_repos': False,
94 'make_current_version_link': True,
95 'current_version_name_source': 'Name',
96 'update_stats': False,
100 'stats_to_carbon': False,
102 'build_server_always': False,
103 'keystore': 'keystore.jks',
104 'smartcardoptions': [],
114 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
115 'repo_name': "My First FDroid Repo Demo",
116 'repo_icon': "fdroid-icon.png",
117 'repo_description': '''
118 This is a repository of apps to be used with FDroid. Applications in this
119 repository are either official binaries built by the original application
120 developers, or are binaries built from source by the admin of f-droid.org
121 using the tools on https://gitlab.com/u/fdroid.
127 def setup_global_opts(parser):
128 parser.add_argument("-v", "--verbose", action="store_true", default=False,
129 help=_("Spew out even more information than normal"))
130 parser.add_argument("-q", "--quiet", action="store_true", default=False,
131 help=_("Restrict output to warnings and errors"))
134 def fill_config_defaults(thisconfig):
135 for k, v in default_config.items():
136 if k not in thisconfig:
139 # Expand paths (~users and $vars)
140 def expand_path(path):
144 path = os.path.expanduser(path)
145 path = os.path.expandvars(path)
150 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
155 thisconfig[k + '_orig'] = v
157 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
158 if thisconfig['java_paths'] is None:
159 thisconfig['java_paths'] = dict()
161 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
162 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
163 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
164 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
165 if os.getenv('JAVA_HOME') is not None:
166 pathlist.append(os.getenv('JAVA_HOME'))
167 if os.getenv('PROGRAMFILES') is not None:
168 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
169 for d in sorted(pathlist):
170 if os.path.islink(d):
172 j = os.path.basename(d)
173 # the last one found will be the canonical one, so order appropriately
175 r'^1\.([6-9])\.0\.jdk$', # OSX
176 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
177 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
178 r'^jdk([6-9])-openjdk$', # Arch
179 r'^java-([6-9])-openjdk$', # Arch
180 r'^java-([6-9])-jdk$', # Arch (oracle)
181 r'^java-1\.([6-9])\.0-.*$', # RedHat
182 r'^java-([6-9])-oracle$', # Debian WebUpd8
183 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
184 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
186 m = re.match(regex, j)
189 for p in [d, os.path.join(d, 'Contents', 'Home')]:
190 if os.path.exists(os.path.join(p, 'bin', 'javac')):
191 thisconfig['java_paths'][m.group(1)] = p
193 for java_version in ('7', '8', '9'):
194 if java_version not in thisconfig['java_paths']:
196 java_home = thisconfig['java_paths'][java_version]
197 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
198 if os.path.exists(jarsigner):
199 thisconfig['jarsigner'] = jarsigner
200 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
201 break # Java7 is preferred, so quit if found
203 for k in ['ndk_paths', 'java_paths']:
209 thisconfig[k][k2] = exp
210 thisconfig[k][k2 + '_orig'] = v
213 def regsub_file(pattern, repl, path):
214 with open(path, 'rb') as f:
216 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
217 with open(path, 'wb') as f:
221 def read_config(opts, config_file='config.py'):
222 """Read the repository config
224 The config is read from config_file, which is in the current
225 directory when any of the repo management commands are used. If
226 there is a local metadata file in the git repo, then config.py is
227 not required, just use defaults.
230 global config, options
232 if config is not None:
239 if os.path.isfile(config_file):
240 logging.debug(_("Reading '{config_file}'").format(config_file=config_file))
241 with io.open(config_file, "rb") as f:
242 code = compile(f.read(), config_file, 'exec')
243 exec(code, None, config)
245 logging.warning(_("No 'config.py' found, using defaults."))
247 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
249 if not type(config[k]) in (str, list, tuple):
251 _("'{field}' will be in random order! Use () or [] brackets if order is important!")
254 # smartcardoptions must be a list since its command line args for Popen
255 if 'smartcardoptions' in config:
256 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
257 elif 'keystore' in config and config['keystore'] == 'NONE':
258 # keystore='NONE' means use smartcard, these are required defaults
259 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
260 'SunPKCS11-OpenSC', '-providerClass',
261 'sun.security.pkcs11.SunPKCS11',
262 '-providerArg', 'opensc-fdroid.cfg']
264 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
265 st = os.stat(config_file)
266 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
267 logging.warning(_("unsafe permissions on '{config_file}' (should be 0600)!")
268 .format(config_file=config_file))
270 fill_config_defaults(config)
272 for k in ["repo_description", "archive_description"]:
274 config[k] = clean_description(config[k])
276 if 'serverwebroot' in config:
277 if isinstance(config['serverwebroot'], str):
278 roots = [config['serverwebroot']]
279 elif all(isinstance(item, str) for item in config['serverwebroot']):
280 roots = config['serverwebroot']
282 raise TypeError(_('only accepts strings, lists, and tuples'))
284 for rootstr in roots:
285 # since this is used with rsync, where trailing slashes have
286 # meaning, ensure there is always a trailing slash
287 if rootstr[-1] != '/':
289 rootlist.append(rootstr.replace('//', '/'))
290 config['serverwebroot'] = rootlist
292 if 'servergitmirrors' in config:
293 if isinstance(config['servergitmirrors'], str):
294 roots = [config['servergitmirrors']]
295 elif all(isinstance(item, str) for item in config['servergitmirrors']):
296 roots = config['servergitmirrors']
298 raise TypeError(_('only accepts strings, lists, and tuples'))
299 config['servergitmirrors'] = roots
304 def find_sdk_tools_cmd(cmd):
305 '''find a working path to a tool from the Android SDK'''
308 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
309 # try to find a working path to this command, in all the recent possible paths
310 if 'build_tools' in config:
311 build_tools = os.path.join(config['sdk_path'], 'build-tools')
312 # if 'build_tools' was manually set and exists, check only that one
313 configed_build_tools = os.path.join(build_tools, config['build_tools'])
314 if os.path.exists(configed_build_tools):
315 tooldirs.append(configed_build_tools)
317 # no configed version, so hunt known paths for it
318 for f in sorted(os.listdir(build_tools), reverse=True):
319 if os.path.isdir(os.path.join(build_tools, f)):
320 tooldirs.append(os.path.join(build_tools, f))
321 tooldirs.append(build_tools)
322 sdk_tools = os.path.join(config['sdk_path'], 'tools')
323 if os.path.exists(sdk_tools):
324 tooldirs.append(sdk_tools)
325 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
326 if os.path.exists(sdk_platform_tools):
327 tooldirs.append(sdk_platform_tools)
328 tooldirs.append('/usr/bin')
330 path = os.path.join(d, cmd)
331 if os.path.isfile(path):
333 test_aapt_version(path)
335 # did not find the command, exit with error message
336 ensure_build_tools_exists(config)
339 def test_aapt_version(aapt):
340 '''Check whether the version of aapt is new enough'''
341 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
342 if output is None or output == '':
343 logging.error(_("'{path}' failed to execute!").format(path=aapt))
345 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
350 # the Debian package has the version string like "v0.2-23.0.2"
351 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
352 logging.warning(_("'{aapt}' is too old, fdroid requires build-tools-23.0.0 or newer!")
355 logging.warning(_('Unknown version of aapt, might cause problems: ') + output)
358 def test_sdk_exists(thisconfig):
359 if 'sdk_path' not in thisconfig:
360 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
361 test_aapt_version(thisconfig['aapt'])
364 logging.error(_("'sdk_path' not set in 'config.py'!"))
366 if thisconfig['sdk_path'] == default_config['sdk_path']:
367 logging.error(_('No Android SDK found!'))
368 logging.error(_('You can use ANDROID_HOME to set the path to your SDK, i.e.:'))
369 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
371 if not os.path.exists(thisconfig['sdk_path']):
372 logging.critical(_("Android SDK path '{path}' does not exist!")
373 .format(path=thisconfig['sdk_path']))
375 if not os.path.isdir(thisconfig['sdk_path']):
376 logging.critical(_("Android SDK path '{path}' is not a directory!")
377 .format(path=thisconfig['sdk_path']))
379 for d in ['build-tools', 'platform-tools', 'tools']:
380 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
381 logging.critical(_("Android SDK '{path}' does not have '{dirname}' installed!")
382 .format(path=thisconfig['sdk_path'], dirname=d))
387 def ensure_build_tools_exists(thisconfig):
388 if not test_sdk_exists(thisconfig):
389 raise FDroidException(_("Android SDK not found!"))
390 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
391 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
392 if not os.path.isdir(versioned_build_tools):
393 raise FDroidException(
394 _("Android Build Tools path '{path}' does not exist!")
395 .format(path=versioned_build_tools))
398 def get_local_metadata_files():
399 '''get any metadata files local to an app's source repo
401 This tries to ignore anything that does not count as app metdata,
402 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
405 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
408 def read_pkg_args(args, allow_vercodes=False):
410 :param args: arguments in the form of multiple appid:[vc] strings
411 :returns: a dictionary with the set of vercodes specified for each package
419 if allow_vercodes and ':' in p:
420 package, vercode = p.split(':')
422 package, vercode = p, None
423 if package not in vercodes:
424 vercodes[package] = [vercode] if vercode else []
426 elif vercode and vercode not in vercodes[package]:
427 vercodes[package] += [vercode] if vercode else []
432 def read_app_args(args, allapps, allow_vercodes=False):
434 On top of what read_pkg_args does, this returns the whole app metadata, but
435 limiting the builds list to the builds matching the vercodes specified.
438 vercodes = read_pkg_args(args, allow_vercodes)
444 for appid, app in allapps.items():
445 if appid in vercodes:
448 if len(apps) != len(vercodes):
451 logging.critical(_("No such package: %s") % p)
452 raise FDroidException(_("Found invalid appids in arguments"))
454 raise FDroidException(_("No packages specified"))
457 for appid, app in apps.items():
461 app.builds = [b for b in app.builds if b.versionCode in vc]
462 if len(app.builds) != len(vercodes[appid]):
464 allvcs = [b.versionCode for b in app.builds]
465 for v in vercodes[appid]:
467 logging.critical(_("No such versionCode {versionCode} for app {appid}")
468 .format(versionCode=v, appid=appid))
471 raise FDroidException(_("Found invalid versionCodes for some apps"))
476 def get_extension(filename):
477 base, ext = os.path.splitext(filename)
480 return base, ext.lower()[1:]
483 def has_extension(filename, ext):
484 _, f_ext = get_extension(filename)
488 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
491 def clean_description(description):
492 'Remove unneeded newlines and spaces from a block of description text'
494 # this is split up by paragraph to make removing the newlines easier
495 for paragraph in re.split(r'\n\n', description):
496 paragraph = re.sub('\r', '', paragraph)
497 paragraph = re.sub('\n', ' ', paragraph)
498 paragraph = re.sub(' {2,}', ' ', paragraph)
499 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
500 returnstring += paragraph + '\n\n'
501 return returnstring.rstrip('\n')
504 def publishednameinfo(filename):
505 filename = os.path.basename(filename)
506 m = publish_name_regex.match(filename)
508 result = (m.group(1), m.group(2))
509 except AttributeError:
510 raise FDroidException(_("Invalid name for published file: %s") % filename)
514 def get_release_filename(app, build):
516 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
518 return "%s_%s.apk" % (app.id, build.versionCode)
521 def get_toolsversion_logname(app, build):
522 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
525 def getsrcname(app, build):
526 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
538 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
541 def get_build_dir(app):
542 '''get the dir that this app will be built in'''
544 if app.RepoType == 'srclib':
545 return os.path.join('build', 'srclib', app.Repo)
547 return os.path.join('build', app.id)
551 '''checkout code from VCS and return instance of vcs and the build dir'''
552 build_dir = get_build_dir(app)
554 # Set up vcs interface and make sure we have the latest code...
555 logging.debug("Getting {0} vcs interface for {1}"
556 .format(app.RepoType, app.Repo))
557 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
561 vcs = getvcs(app.RepoType, remote, build_dir)
563 return vcs, build_dir
566 def getvcs(vcstype, remote, local):
568 return vcs_git(remote, local)
569 if vcstype == 'git-svn':
570 return vcs_gitsvn(remote, local)
572 return vcs_hg(remote, local)
574 return vcs_bzr(remote, local)
575 if vcstype == 'srclib':
576 if local != os.path.join('build', 'srclib', remote):
577 raise VCSException("Error: srclib paths are hard-coded!")
578 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
580 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
581 raise VCSException("Invalid vcs type " + vcstype)
584 def getsrclibvcs(name):
585 if name not in fdroidserver.metadata.srclibs:
586 raise VCSException("Missing srclib " + name)
587 return fdroidserver.metadata.srclibs[name]['Repo Type']
592 def __init__(self, remote, local):
594 # svn, git-svn and bzr may require auth
596 if self.repotype() in ('git-svn', 'bzr'):
598 if self.repotype == 'git-svn':
599 raise VCSException("Authentication is not supported for git-svn")
600 self.username, remote = remote.split('@')
601 if ':' not in self.username:
602 raise VCSException(_("Password required with username"))
603 self.username, self.password = self.username.split(':')
607 self.clone_failed = False
608 self.refreshed = False
614 def gotorevision(self, rev, refresh=True):
615 """Take the local repository to a clean version of the given
616 revision, which is specificed in the VCS's native
617 format. Beforehand, the repository can be dirty, or even
618 non-existent. If the repository does already exist locally, it
619 will be updated from the origin, but only once in the lifetime
620 of the vcs object. None is acceptable for 'rev' if you know
621 you are cloning a clean copy of the repo - otherwise it must
622 specify a valid revision.
625 if self.clone_failed:
626 raise VCSException(_("Downloading the repository already failed once, not trying again."))
628 # The .fdroidvcs-id file for a repo tells us what VCS type
629 # and remote that directory was created from, allowing us to drop it
630 # automatically if either of those things changes.
631 fdpath = os.path.join(self.local, '..',
632 '.fdroidvcs-' + os.path.basename(self.local))
633 fdpath = os.path.normpath(fdpath)
634 cdata = self.repotype() + ' ' + self.remote
637 if os.path.exists(self.local):
638 if os.path.exists(fdpath):
639 with open(fdpath, 'r') as f:
640 fsdata = f.read().strip()
645 logging.info("Repository details for %s changed - deleting" % (
649 logging.info("Repository details for %s missing - deleting" % (
652 shutil.rmtree(self.local)
656 self.refreshed = True
659 self.gotorevisionx(rev)
660 except FDroidException as e:
663 # If necessary, write the .fdroidvcs file.
664 if writeback and not self.clone_failed:
665 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
666 with open(fdpath, 'w+') as f:
672 def gotorevisionx(self, rev): # pylint: disable=unused-argument
673 """Derived classes need to implement this.
675 It's called once basic checking has been performed.
677 raise VCSException("This VCS type doesn't define gotorevisionx")
679 # Initialise and update submodules
680 def initsubmodules(self):
681 raise VCSException('Submodules not supported for this vcs type')
683 # Get a list of all known tags
685 if not self._gettags:
686 raise VCSException('gettags not supported for this vcs type')
688 for tag in self._gettags():
689 if re.match('[-A-Za-z0-9_. /]+$', tag):
693 def latesttags(self):
694 """Get a list of all the known tags, sorted from newest to oldest"""
695 raise VCSException('latesttags not supported for this vcs type')
698 """Get current commit reference (hash, revision, etc)"""
699 raise VCSException('getref not supported for this vcs type')
702 """Returns the srclib (name, path) used in setting up the current revision, or None."""
712 """If the local directory exists, but is somehow not a git repository,
713 git will traverse up the directory tree until it finds one
714 that is (i.e. fdroidserver) and then we'll proceed to destroy
715 it! This is called as a safety check.
719 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
720 result = p.output.rstrip()
721 if not result.endswith(self.local):
722 raise VCSException('Repository mismatch')
724 def gotorevisionx(self, rev):
725 if not os.path.exists(self.local):
727 p = FDroidPopen(['git', 'clone', self.remote, self.local])
728 if p.returncode != 0:
729 self.clone_failed = True
730 raise VCSException("Git clone failed", p.output)
734 # Discard any working tree changes
735 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
736 'git', 'reset', '--hard'], cwd=self.local, output=False)
737 if p.returncode != 0:
738 raise VCSException(_("Git reset failed"), p.output)
739 # Remove untracked files now, in case they're tracked in the target
740 # revision (it happens!)
741 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
742 'git', 'clean', '-dffx'], cwd=self.local, output=False)
743 if p.returncode != 0:
744 raise VCSException(_("Git clean failed"), p.output)
745 if not self.refreshed:
746 # Get latest commits and tags from remote
747 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
748 if p.returncode != 0:
749 raise VCSException(_("Git fetch failed"), p.output)
750 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
751 if p.returncode != 0:
752 raise VCSException(_("Git fetch failed"), p.output)
753 # Recreate origin/HEAD as git clone would do it, in case it disappeared
754 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
755 if p.returncode != 0:
756 lines = p.output.splitlines()
757 if 'Multiple remote HEAD branches' not in lines[0]:
758 raise VCSException(_("Git remote set-head failed"), p.output)
759 branch = lines[1].split(' ')[-1]
760 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
761 if p2.returncode != 0:
762 raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
763 self.refreshed = True
764 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
765 # a github repo. Most of the time this is the same as origin/master.
766 rev = rev or 'origin/HEAD'
767 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
768 if p.returncode != 0:
769 raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
770 # Get rid of any uncontrolled files left behind
771 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
772 if p.returncode != 0:
773 raise VCSException(_("Git clean failed"), p.output)
775 def initsubmodules(self):
777 submfile = os.path.join(self.local, '.gitmodules')
778 if not os.path.isfile(submfile):
779 raise VCSException(_("No git submodules available"))
781 # fix submodules not accessible without an account and public key auth
782 with open(submfile, 'r') as f:
783 lines = f.readlines()
784 with open(submfile, 'w') as f:
786 if 'git@github.com' in line:
787 line = line.replace('git@github.com:', 'https://github.com/')
788 if 'git@gitlab.com' in line:
789 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
792 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
793 if p.returncode != 0:
794 raise VCSException(_("Git submodule sync failed"), p.output)
795 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
796 if p.returncode != 0:
797 raise VCSException(_("Git submodule update failed"), p.output)
801 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
802 return p.output.splitlines()
804 tag_format = re.compile(r'tag: ([^),]*)')
806 def latesttags(self):
808 p = FDroidPopen(['git', 'log', '--tags',
809 '--simplify-by-decoration', '--pretty=format:%d'],
810 cwd=self.local, output=False)
812 for line in p.output.splitlines():
813 for tag in self.tag_format.findall(line):
818 class vcs_gitsvn(vcs):
824 """If the local directory exists, but is somehow not a git repository,
825 git will traverse up the directory tree until it finds one that
826 is (i.e. fdroidserver) and then we'll proceed to destory it!
827 This is called as a safety check.
830 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
831 result = p.output.rstrip()
832 if not result.endswith(self.local):
833 raise VCSException('Repository mismatch')
835 def gotorevisionx(self, rev):
836 if not os.path.exists(self.local):
838 gitsvn_args = ['git', 'svn', 'clone']
839 if ';' in self.remote:
840 remote_split = self.remote.split(';')
841 for i in remote_split[1:]:
842 if i.startswith('trunk='):
843 gitsvn_args.extend(['-T', i[6:]])
844 elif i.startswith('tags='):
845 gitsvn_args.extend(['-t', i[5:]])
846 elif i.startswith('branches='):
847 gitsvn_args.extend(['-b', i[9:]])
848 gitsvn_args.extend([remote_split[0], self.local])
849 p = FDroidPopen(gitsvn_args, output=False)
850 if p.returncode != 0:
851 self.clone_failed = True
852 raise VCSException("Git svn clone failed", p.output)
854 gitsvn_args.extend([self.remote, self.local])
855 p = FDroidPopen(gitsvn_args, output=False)
856 if p.returncode != 0:
857 self.clone_failed = True
858 raise VCSException("Git svn clone failed", p.output)
862 # Discard any working tree changes
863 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
864 if p.returncode != 0:
865 raise VCSException("Git reset failed", p.output)
866 # Remove untracked files now, in case they're tracked in the target
867 # revision (it happens!)
868 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
869 if p.returncode != 0:
870 raise VCSException("Git clean failed", p.output)
871 if not self.refreshed:
872 # Get new commits, branches and tags from repo
873 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
874 if p.returncode != 0:
875 raise VCSException("Git svn fetch failed")
876 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
877 if p.returncode != 0:
878 raise VCSException("Git svn rebase failed", p.output)
879 self.refreshed = True
881 rev = rev or 'master'
883 nospaces_rev = rev.replace(' ', '%20')
884 # Try finding a svn tag
885 for treeish in ['origin/', '']:
886 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
887 if p.returncode == 0:
889 if p.returncode != 0:
890 # No tag found, normal svn rev translation
891 # Translate svn rev into git format
892 rev_split = rev.split('/')
895 for treeish in ['origin/', '']:
896 if len(rev_split) > 1:
897 treeish += rev_split[0]
898 svn_rev = rev_split[1]
901 # if no branch is specified, then assume trunk (i.e. 'master' branch):
905 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
907 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
908 git_rev = p.output.rstrip()
910 if p.returncode == 0 and git_rev:
913 if p.returncode != 0 or not git_rev:
914 # Try a plain git checkout as a last resort
915 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
916 if p.returncode != 0:
917 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
919 # Check out the git rev equivalent to the svn rev
920 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
921 if p.returncode != 0:
922 raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
924 # Get rid of any uncontrolled files left behind
925 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
926 if p.returncode != 0:
927 raise VCSException(_("Git clean failed"), p.output)
931 for treeish in ['origin/', '']:
932 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
938 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
939 if p.returncode != 0:
941 return p.output.strip()
949 def gotorevisionx(self, rev):
950 if not os.path.exists(self.local):
951 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
952 if p.returncode != 0:
953 self.clone_failed = True
954 raise VCSException("Hg clone failed", p.output)
956 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
957 if p.returncode != 0:
958 raise VCSException("Hg status failed", p.output)
959 for line in p.output.splitlines():
960 if not line.startswith('? '):
961 raise VCSException("Unexpected output from hg status -uS: " + line)
962 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
963 if not self.refreshed:
964 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
965 if p.returncode != 0:
966 raise VCSException("Hg pull failed", p.output)
967 self.refreshed = True
969 rev = rev or 'default'
972 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
973 if p.returncode != 0:
974 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
975 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
976 # Also delete untracked files, we have to enable purge extension for that:
977 if "'purge' is provided by the following extension" in p.output:
978 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
979 myfile.write("\n[extensions]\nhgext.purge=\n")
980 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
981 if p.returncode != 0:
982 raise VCSException("HG purge failed", p.output)
983 elif p.returncode != 0:
984 raise VCSException("HG purge failed", p.output)
987 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
988 return p.output.splitlines()[1:]
996 def gotorevisionx(self, rev):
997 if not os.path.exists(self.local):
998 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
999 if p.returncode != 0:
1000 self.clone_failed = True
1001 raise VCSException("Bzr branch failed", p.output)
1003 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1004 if p.returncode != 0:
1005 raise VCSException("Bzr revert failed", p.output)
1006 if not self.refreshed:
1007 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1008 if p.returncode != 0:
1009 raise VCSException("Bzr update failed", p.output)
1010 self.refreshed = True
1012 revargs = list(['-r', rev] if rev else [])
1013 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1014 if p.returncode != 0:
1015 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1018 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1019 return [tag.split(' ')[0].strip() for tag in
1020 p.output.splitlines()]
1023 def unescape_string(string):
1026 if string[0] == '"' and string[-1] == '"':
1029 return string.replace("\\'", "'")
1032 def retrieve_string(app_dir, string, xmlfiles=None):
1034 if not string.startswith('@string/'):
1035 return unescape_string(string)
1037 if xmlfiles is None:
1040 os.path.join(app_dir, 'res'),
1041 os.path.join(app_dir, 'src', 'main', 'res'),
1043 for root, dirs, files in os.walk(res_dir):
1044 if os.path.basename(root) == 'values':
1045 xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1047 name = string[len('@string/'):]
1049 def element_content(element):
1050 if element.text is None:
1052 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1053 return s.decode('utf-8').strip()
1055 for path in xmlfiles:
1056 if not os.path.isfile(path):
1058 xml = parse_xml(path)
1059 element = xml.find('string[@name="' + name + '"]')
1060 if element is not None:
1061 content = element_content(element)
1062 return retrieve_string(app_dir, content, xmlfiles)
1067 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1068 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1071 def manifest_paths(app_dir, flavours):
1072 '''Return list of existing files that will be used to find the highest vercode'''
1074 possible_manifests = \
1075 [os.path.join(app_dir, 'AndroidManifest.xml'),
1076 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1077 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1078 os.path.join(app_dir, 'build.gradle')]
1080 for flavour in flavours:
1081 if flavour == 'yes':
1083 possible_manifests.append(
1084 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1086 return [path for path in possible_manifests if os.path.isfile(path)]
1089 def fetch_real_name(app_dir, flavours):
1090 '''Retrieve the package name. Returns the name, or None if not found.'''
1091 for path in manifest_paths(app_dir, flavours):
1092 if not has_extension(path, 'xml') or not os.path.isfile(path):
1094 logging.debug("fetch_real_name: Checking manifest at " + path)
1095 xml = parse_xml(path)
1096 app = xml.find('application')
1099 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1101 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1102 result = retrieve_string_singleline(app_dir, label)
1104 result = result.strip()
1109 def get_library_references(root_dir):
1111 proppath = os.path.join(root_dir, 'project.properties')
1112 if not os.path.isfile(proppath):
1114 with open(proppath, 'r', encoding='iso-8859-1') as f:
1116 if not line.startswith('android.library.reference.'):
1118 path = line.split('=')[1].strip()
1119 relpath = os.path.join(root_dir, path)
1120 if not os.path.isdir(relpath):
1122 logging.debug("Found subproject at %s" % path)
1123 libraries.append(path)
1127 def ant_subprojects(root_dir):
1128 subprojects = get_library_references(root_dir)
1129 for subpath in subprojects:
1130 subrelpath = os.path.join(root_dir, subpath)
1131 for p in get_library_references(subrelpath):
1132 relp = os.path.normpath(os.path.join(subpath, p))
1133 if relp not in subprojects:
1134 subprojects.insert(0, relp)
1138 def remove_debuggable_flags(root_dir):
1139 # Remove forced debuggable flags
1140 logging.debug("Removing debuggable flags from %s" % root_dir)
1141 for root, dirs, files in os.walk(root_dir):
1142 if 'AndroidManifest.xml' in files:
1143 regsub_file(r'android:debuggable="[^"]*"',
1145 os.path.join(root, 'AndroidManifest.xml'))
1148 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1149 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1150 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1153 def app_matches_packagename(app, package):
1156 appid = app.UpdateCheckName or app.id
1157 if appid is None or appid == "Ignore":
1159 return appid == package
1162 def parse_androidmanifests(paths, app):
1164 Extract some information from the AndroidManifest.xml at the given path.
1165 Returns (version, vercode, package), any or all of which might be None.
1166 All values returned are strings.
1169 ignoreversions = app.UpdateCheckIgnore
1170 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1173 return (None, None, None)
1181 if not os.path.isfile(path):
1184 logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1189 if has_extension(path, 'gradle'):
1190 with open(path, 'r') as f:
1192 if gradle_comment.match(line):
1194 # Grab first occurence of each to avoid running into
1195 # alternative flavours and builds.
1197 matches = psearch_g(line)
1199 s = matches.group(2)
1200 if app_matches_packagename(app, s):
1203 matches = vnsearch_g(line)
1205 version = matches.group(2)
1207 matches = vcsearch_g(line)
1209 vercode = matches.group(1)
1212 xml = parse_xml(path)
1213 if "package" in xml.attrib:
1214 s = xml.attrib["package"]
1215 if app_matches_packagename(app, s):
1217 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1218 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1219 base_dir = os.path.dirname(path)
1220 version = retrieve_string_singleline(base_dir, version)
1221 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1222 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1223 if string_is_integer(a):
1226 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1228 # Remember package name, may be defined separately from version+vercode
1230 package = max_package
1232 logging.debug("..got package={0}, version={1}, vercode={2}"
1233 .format(package, version, vercode))
1235 # Always grab the package name and version name in case they are not
1236 # together with the highest version code
1237 if max_package is None and package is not None:
1238 max_package = package
1239 if max_version is None and version is not None:
1240 max_version = version
1242 if vercode is not None \
1243 and (max_vercode is None or vercode > max_vercode):
1244 if not ignoresearch or not ignoresearch(version):
1245 if version is not None:
1246 max_version = version
1247 if vercode is not None:
1248 max_vercode = vercode
1249 if package is not None:
1250 max_package = package
1252 max_version = "Ignore"
1254 if max_version is None:
1255 max_version = "Unknown"
1257 if max_package and not is_valid_package_name(max_package):
1258 raise FDroidException(_("Invalid package name {0}").format(max_package))
1260 return (max_version, max_vercode, max_package)
1263 def is_valid_package_name(name):
1264 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1267 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1268 raw=False, prepare=True, preponly=False, refresh=True,
1270 """Get the specified source library.
1272 Returns the path to it. Normally this is the path to be used when
1273 referencing it, which may be a subdirectory of the actual project. If
1274 you want the base directory of the project, pass 'basepath=True'.
1283 name, ref = spec.split('@')
1285 number, name = name.split(':', 1)
1287 name, subdir = name.split('/', 1)
1289 if name not in fdroidserver.metadata.srclibs:
1290 raise VCSException('srclib ' + name + ' not found.')
1292 srclib = fdroidserver.metadata.srclibs[name]
1294 sdir = os.path.join(srclib_dir, name)
1297 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1298 vcs.srclib = (name, number, sdir)
1300 vcs.gotorevision(ref, refresh)
1307 libdir = os.path.join(sdir, subdir)
1308 elif srclib["Subdir"]:
1309 for subdir in srclib["Subdir"]:
1310 libdir_candidate = os.path.join(sdir, subdir)
1311 if os.path.exists(libdir_candidate):
1312 libdir = libdir_candidate
1318 remove_signing_keys(sdir)
1319 remove_debuggable_flags(sdir)
1323 if srclib["Prepare"]:
1324 cmd = replace_config_vars(srclib["Prepare"], build)
1326 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1327 if p.returncode != 0:
1328 raise BuildException("Error running prepare command for srclib %s"
1334 return (name, number, libdir)
1337 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1340 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1341 """ Prepare the source code for a particular build
1343 :param vcs: the appropriate vcs object for the application
1344 :param app: the application details from the metadata
1345 :param build: the build details from the metadata
1346 :param build_dir: the path to the build directory, usually 'build/app.id'
1347 :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1348 :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1350 Returns the (root, srclibpaths) where:
1351 :param root: is the root directory, which may be the same as 'build_dir' or may
1352 be a subdirectory of it.
1353 :param srclibpaths: is information on the srclibs being used
1356 # Optionally, the actual app source can be in a subdirectory
1358 root_dir = os.path.join(build_dir, build.subdir)
1360 root_dir = build_dir
1362 # Get a working copy of the right revision
1363 logging.info("Getting source for revision " + build.commit)
1364 vcs.gotorevision(build.commit, refresh)
1366 # Initialise submodules if required
1367 if build.submodules:
1368 logging.info(_("Initialising submodules"))
1369 vcs.initsubmodules()
1371 # Check that a subdir (if we're using one) exists. This has to happen
1372 # after the checkout, since it might not exist elsewhere
1373 if not os.path.exists(root_dir):
1374 raise BuildException('Missing subdir ' + root_dir)
1376 # Run an init command if one is required
1378 cmd = replace_config_vars(build.init, build)
1379 logging.info("Running 'init' commands in %s" % root_dir)
1381 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1382 if p.returncode != 0:
1383 raise BuildException("Error running init command for %s:%s" %
1384 (app.id, build.versionName), p.output)
1386 # Apply patches if any
1388 logging.info("Applying patches")
1389 for patch in build.patch:
1390 patch = patch.strip()
1391 logging.info("Applying " + patch)
1392 patch_path = os.path.join('metadata', app.id, patch)
1393 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1394 if p.returncode != 0:
1395 raise BuildException("Failed to apply patch %s" % patch_path)
1397 # Get required source libraries
1400 logging.info("Collecting source libraries")
1401 for lib in build.srclibs:
1402 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1403 refresh=refresh, build=build))
1405 for name, number, libpath in srclibpaths:
1406 place_srclib(root_dir, int(number) if number else None, libpath)
1408 basesrclib = vcs.getsrclib()
1409 # If one was used for the main source, add that too.
1411 srclibpaths.append(basesrclib)
1413 # Update the local.properties file
1414 localprops = [os.path.join(build_dir, 'local.properties')]
1416 parts = build.subdir.split(os.sep)
1419 cur = os.path.join(cur, d)
1420 localprops += [os.path.join(cur, 'local.properties')]
1421 for path in localprops:
1423 if os.path.isfile(path):
1424 logging.info("Updating local.properties file at %s" % path)
1425 with open(path, 'r', encoding='iso-8859-1') as f:
1429 logging.info("Creating local.properties file at %s" % path)
1430 # Fix old-fashioned 'sdk-location' by copying
1431 # from sdk.dir, if necessary
1433 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1434 re.S | re.M).group(1)
1435 props += "sdk-location=%s\n" % sdkloc
1437 props += "sdk.dir=%s\n" % config['sdk_path']
1438 props += "sdk-location=%s\n" % config['sdk_path']
1439 ndk_path = build.ndk_path()
1440 # if for any reason the path isn't valid or the directory
1441 # doesn't exist, some versions of Gradle will error with a
1442 # cryptic message (even if the NDK is not even necessary).
1443 # https://gitlab.com/fdroid/fdroidserver/issues/171
1444 if ndk_path and os.path.exists(ndk_path):
1446 props += "ndk.dir=%s\n" % ndk_path
1447 props += "ndk-location=%s\n" % ndk_path
1448 # Add java.encoding if necessary
1450 props += "java.encoding=%s\n" % build.encoding
1451 with open(path, 'w', encoding='iso-8859-1') as f:
1455 if build.build_method() == 'gradle':
1456 flavours = build.gradle
1459 n = build.target.split('-')[1]
1460 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1461 r'compileSdkVersion %s' % n,
1462 os.path.join(root_dir, 'build.gradle'))
1464 # Remove forced debuggable flags
1465 remove_debuggable_flags(root_dir)
1467 # Insert version code and number into the manifest if necessary
1468 if build.forceversion:
1469 logging.info("Changing the version name")
1470 for path in manifest_paths(root_dir, flavours):
1471 if not os.path.isfile(path):
1473 if has_extension(path, 'xml'):
1474 regsub_file(r'android:versionName="[^"]*"',
1475 r'android:versionName="%s"' % build.versionName,
1477 elif has_extension(path, 'gradle'):
1478 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1479 r"""\1versionName '%s'""" % build.versionName,
1482 if build.forcevercode:
1483 logging.info("Changing the version code")
1484 for path in manifest_paths(root_dir, flavours):
1485 if not os.path.isfile(path):
1487 if has_extension(path, 'xml'):
1488 regsub_file(r'android:versionCode="[^"]*"',
1489 r'android:versionCode="%s"' % build.versionCode,
1491 elif has_extension(path, 'gradle'):
1492 regsub_file(r'versionCode[ =]+[0-9]+',
1493 r'versionCode %s' % build.versionCode,
1496 # Delete unwanted files
1498 logging.info(_("Removing specified files"))
1499 for part in getpaths(build_dir, build.rm):
1500 dest = os.path.join(build_dir, part)
1501 logging.info("Removing {0}".format(part))
1502 if os.path.lexists(dest):
1503 if os.path.islink(dest):
1504 FDroidPopen(['unlink', dest], output=False)
1506 FDroidPopen(['rm', '-rf', dest], output=False)
1508 logging.info("...but it didn't exist")
1510 remove_signing_keys(build_dir)
1512 # Add required external libraries
1514 logging.info("Collecting prebuilt libraries")
1515 libsdir = os.path.join(root_dir, 'libs')
1516 if not os.path.exists(libsdir):
1518 for lib in build.extlibs:
1520 logging.info("...installing extlib {0}".format(lib))
1521 libf = os.path.basename(lib)
1522 libsrc = os.path.join(extlib_dir, lib)
1523 if not os.path.exists(libsrc):
1524 raise BuildException("Missing extlib file {0}".format(libsrc))
1525 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1527 # Run a pre-build command if one is required
1529 logging.info("Running 'prebuild' commands in %s" % root_dir)
1531 cmd = replace_config_vars(build.prebuild, build)
1533 # Substitute source library paths into prebuild commands
1534 for name, number, libpath in srclibpaths:
1535 libpath = os.path.relpath(libpath, root_dir)
1536 cmd = cmd.replace('$$' + name + '$$', libpath)
1538 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1539 if p.returncode != 0:
1540 raise BuildException("Error running prebuild command for %s:%s" %
1541 (app.id, build.versionName), p.output)
1543 # Generate (or update) the ant build file, build.xml...
1544 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1545 parms = ['android', 'update', 'lib-project']
1546 lparms = ['android', 'update', 'project']
1549 parms += ['-t', build.target]
1550 lparms += ['-t', build.target]
1551 if build.androidupdate:
1552 update_dirs = build.androidupdate
1554 update_dirs = ant_subprojects(root_dir) + ['.']
1556 for d in update_dirs:
1557 subdir = os.path.join(root_dir, d)
1559 logging.debug("Updating main project")
1560 cmd = parms + ['-p', d]
1562 logging.debug("Updating subproject %s" % d)
1563 cmd = lparms + ['-p', d]
1564 p = SdkToolsPopen(cmd, cwd=root_dir)
1565 # Check to see whether an error was returned without a proper exit
1566 # code (this is the case for the 'no target set or target invalid'
1568 if p.returncode != 0 or p.output.startswith("Error: "):
1569 raise BuildException("Failed to update project at %s" % d, p.output)
1570 # Clean update dirs via ant
1572 logging.info("Cleaning subproject %s" % d)
1573 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1575 return (root_dir, srclibpaths)
1578 def getpaths_map(build_dir, globpaths):
1579 """Extend via globbing the paths from a field and return them as a map from original path to resulting paths"""
1583 full_path = os.path.join(build_dir, p)
1584 full_path = os.path.normpath(full_path)
1585 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1587 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1591 def getpaths(build_dir, globpaths):
1592 """Extend via globbing the paths from a field and return them as a set"""
1593 paths_map = getpaths_map(build_dir, globpaths)
1595 for k, v in paths_map.items():
1602 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1606 """permanent store of existing APKs with the date they were added
1608 This is currently the only way to permanently store the "updated"
1613 '''Load filename/date info about previously seen APKs
1615 Since the appid and date strings both will never have spaces,
1616 this is parsed as a list from the end to allow the filename to
1617 have any combo of spaces.
1620 self.path = os.path.join('stats', 'known_apks.txt')
1622 if os.path.isfile(self.path):
1623 with open(self.path, 'r', encoding='utf8') as f:
1625 t = line.rstrip().split(' ')
1627 self.apks[t[0]] = (t[1], None)
1630 date = datetime.strptime(t[-1], '%Y-%m-%d')
1631 filename = line[0:line.rfind(appid) - 1]
1632 self.apks[filename] = (appid, date)
1633 self.changed = False
1635 def writeifchanged(self):
1636 if not self.changed:
1639 if not os.path.exists('stats'):
1643 for apk, app in self.apks.items():
1645 line = apk + ' ' + appid
1647 line += ' ' + added.strftime('%Y-%m-%d')
1650 with open(self.path, 'w', encoding='utf8') as f:
1651 for line in sorted(lst, key=natural_key):
1652 f.write(line + '\n')
1654 def recordapk(self, apkName, app, default_date=None):
1656 Record an apk (if it's new, otherwise does nothing)
1657 Returns the date it was added as a datetime instance
1659 if apkName not in self.apks:
1660 if default_date is None:
1661 default_date = datetime.utcnow()
1662 self.apks[apkName] = (app, default_date)
1664 _, added = self.apks[apkName]
1667 def getapp(self, apkname):
1668 """Look up information - given the 'apkname', returns (app id, date added/None).
1670 Or returns None for an unknown apk.
1672 if apkname in self.apks:
1673 return self.apks[apkname]
1676 def getlatest(self, num):
1677 """Get the most recent 'num' apps added to the repo, as a list of package ids with the most recent first"""
1679 for apk, app in self.apks.items():
1683 if apps[appid] > added:
1687 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1688 lst = [app for app, _ignored in sortedapps]
1693 def get_file_extension(filename):
1694 """get the normalized file extension, can be blank string but never None"""
1695 if isinstance(filename, bytes):
1696 filename = filename.decode('utf-8')
1697 return os.path.splitext(filename)[1].lower()[1:]
1700 def get_apk_debuggable_aapt(apkfile):
1701 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1703 if p.returncode != 0:
1704 raise FDroidException(_("Failed to get APK manifest information"))
1705 for line in p.output.splitlines():
1706 if 'android:debuggable' in line and not line.endswith('0x0'):
1711 def get_apk_debuggable_androguard(apkfile):
1713 from androguard.core.bytecodes.apk import APK
1715 raise FDroidException("androguard library is not installed and aapt not present")
1717 apkobject = APK(apkfile)
1718 if apkobject.is_valid_APK():
1719 debuggable = apkobject.get_element("application", "debuggable")
1720 if debuggable is not None:
1721 return bool(strtobool(debuggable))
1725 def isApkAndDebuggable(apkfile):
1726 """Returns True if the given file is an APK and is debuggable
1728 :param apkfile: full path to the apk to check"""
1730 if get_file_extension(apkfile) != 'apk':
1733 if SdkToolsPopen(['aapt', 'version'], output=False):
1734 return get_apk_debuggable_aapt(apkfile)
1736 return get_apk_debuggable_androguard(apkfile)
1739 def get_apk_id_aapt(apkfile):
1740 """Extrat identification information from APK using aapt.
1742 :param apkfile: path to an APK file.
1743 :returns: triplet (appid, version code, version name)
1745 r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1746 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1747 for line in p.output.splitlines():
1750 return m.group('appid'), m.group('vercode'), m.group('vername')
1751 raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1752 .format(apkfilename=apkfile))
1757 self.returncode = None
1761 def SdkToolsPopen(commands, cwd=None, output=True):
1763 if cmd not in config:
1764 config[cmd] = find_sdk_tools_cmd(commands[0])
1765 abscmd = config[cmd]
1767 raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1769 test_aapt_version(config['aapt'])
1770 return FDroidPopen([abscmd] + commands[1:],
1771 cwd=cwd, output=output)
1774 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1776 Run a command and capture the possibly huge output as bytes.
1778 :param commands: command and argument list like in subprocess.Popen
1779 :param cwd: optionally specifies a working directory
1780 :param envs: a optional dictionary of environment variables and their values
1781 :returns: A PopenResult.
1786 set_FDroidPopen_env()
1788 process_env = env.copy()
1789 if envs is not None and len(envs) > 0:
1790 process_env.update(envs)
1793 cwd = os.path.normpath(cwd)
1794 logging.debug("Directory: %s" % cwd)
1795 logging.debug("> %s" % ' '.join(commands))
1797 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1798 result = PopenResult()
1801 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1802 stdout=subprocess.PIPE, stderr=stderr_param)
1803 except OSError as e:
1804 raise BuildException("OSError while trying to execute " +
1805 ' '.join(commands) + ': ' + str(e))
1807 if not stderr_to_stdout and options.verbose:
1808 stderr_queue = Queue()
1809 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1811 while not stderr_reader.eof():
1812 while not stderr_queue.empty():
1813 line = stderr_queue.get()
1814 sys.stderr.buffer.write(line)
1819 stdout_queue = Queue()
1820 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1823 # Check the queue for output (until there is no more to get)
1824 while not stdout_reader.eof():
1825 while not stdout_queue.empty():
1826 line = stdout_queue.get()
1827 if output and options.verbose:
1828 # Output directly to console
1829 sys.stderr.buffer.write(line)
1835 result.returncode = p.wait()
1836 result.output = buf.getvalue()
1838 # make sure all filestreams of the subprocess are closed
1839 for streamvar in ['stdin', 'stdout', 'stderr']:
1840 if hasattr(p, streamvar):
1841 stream = getattr(p, streamvar)
1847 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1849 Run a command and capture the possibly huge output as a str.
1851 :param commands: command and argument list like in subprocess.Popen
1852 :param cwd: optionally specifies a working directory
1853 :param envs: a optional dictionary of environment variables and their values
1854 :returns: A PopenResult.
1856 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1857 result.output = result.output.decode('utf-8', 'ignore')
1861 gradle_comment = re.compile(r'[ ]*//')
1862 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1863 gradle_line_matches = [
1864 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1865 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1866 re.compile(r'.*\.readLine\(.*'),
1870 def remove_signing_keys(build_dir):
1871 for root, dirs, files in os.walk(build_dir):
1872 if 'build.gradle' in files:
1873 path = os.path.join(root, 'build.gradle')
1875 with open(path, "r", encoding='utf8') as o:
1876 lines = o.readlines()
1882 with open(path, "w", encoding='utf8') as o:
1883 while i < len(lines):
1886 while line.endswith('\\\n'):
1887 line = line.rstrip('\\\n') + lines[i]
1890 if gradle_comment.match(line):
1895 opened += line.count('{')
1896 opened -= line.count('}')
1899 if gradle_signing_configs.match(line):
1904 if any(s.match(line) for s in gradle_line_matches):
1912 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1915 'project.properties',
1917 'default.properties',
1918 'ant.properties', ]:
1919 if propfile in files:
1920 path = os.path.join(root, propfile)
1922 with open(path, "r", encoding='iso-8859-1') as o:
1923 lines = o.readlines()
1927 with open(path, "w", encoding='iso-8859-1') as o:
1929 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1936 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1939 def set_FDroidPopen_env(build=None):
1941 set up the environment variables for the build environment
1943 There is only a weak standard, the variables used by gradle, so also set
1944 up the most commonly used environment variables for SDK and NDK. Also, if
1945 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1947 global env, orig_path
1951 orig_path = env['PATH']
1952 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1953 env[n] = config['sdk_path']
1954 for k, v in config['java_paths'].items():
1955 env['JAVA%s_HOME' % k] = v
1957 missinglocale = True
1958 for k, v in env.items():
1959 if k == 'LANG' and v != 'C':
1960 missinglocale = False
1962 missinglocale = False
1964 env['LANG'] = 'en_US.UTF-8'
1966 if build is not None:
1967 path = build.ndk_path()
1968 paths = orig_path.split(os.pathsep)
1969 if path not in paths:
1970 paths = [path] + paths
1971 env['PATH'] = os.pathsep.join(paths)
1972 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1973 env[n] = build.ndk_path()
1976 def replace_build_vars(cmd, build):
1977 cmd = cmd.replace('$$COMMIT$$', build.commit)
1978 cmd = cmd.replace('$$VERSION$$', build.versionName)
1979 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1983 def replace_config_vars(cmd, build):
1984 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1985 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1986 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1987 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1988 if build is not None:
1989 cmd = replace_build_vars(cmd, build)
1993 def place_srclib(root_dir, number, libpath):
1996 relpath = os.path.relpath(libpath, root_dir)
1997 proppath = os.path.join(root_dir, 'project.properties')
2000 if os.path.isfile(proppath):
2001 with open(proppath, "r", encoding='iso-8859-1') as o:
2002 lines = o.readlines()
2004 with open(proppath, "w", encoding='iso-8859-1') as o:
2007 if line.startswith('android.library.reference.%d=' % number):
2008 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2013 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2016 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2019 def signer_fingerprint_short(sig):
2020 """Obtain shortened sha256 signing-key fingerprint for pkcs7 signature.
2022 Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2023 for a given pkcs7 signature.
2025 :param sig: Contents of an APK signature.
2026 :returns: shortened signing-key fingerprint.
2028 return signer_fingerprint(sig)[:7]
2031 def signer_fingerprint(sig):
2032 """Obtain sha256 signing-key fingerprint for pkcs7 signature.
2034 Extracts hexadecimal sha256 signing-key fingerprint string
2035 for a given pkcs7 signature.
2037 :param: Contents of an APK signature.
2038 :returns: shortened signature fingerprint.
2040 cert_encoded = get_certificate(sig)
2041 return hashlib.sha256(cert_encoded).hexdigest()
2044 def apk_signer_fingerprint(apk_path):
2045 """Obtain sha256 signing-key fingerprint for APK.
2047 Extracts hexadecimal sha256 signing-key fingerprint string
2050 :param apkpath: path to APK
2051 :returns: signature fingerprint
2054 with zipfile.ZipFile(apk_path, 'r') as apk:
2055 certs = [n for n in apk.namelist() if CERT_PATH_REGEX.match(n)]
2058 logging.error("Found no signing certificates on %s" % apk_path)
2061 logging.error("Found multiple signing certificates on %s" % apk_path)
2064 cert = apk.read(certs[0])
2065 return signer_fingerprint(cert)
2068 def apk_signer_fingerprint_short(apk_path):
2069 """Obtain shortened sha256 signing-key fingerprint for APK.
2071 Extracts the first 7 hexadecimal digits of sha256 signing-key fingerprint
2072 for a given pkcs7 APK.
2074 :param apk_path: path to APK
2075 :returns: shortened signing-key fingerprint
2077 return apk_signer_fingerprint(apk_path)[:7]
2080 def metadata_get_sigdir(appid, vercode=None):
2081 """Get signature directory for app"""
2083 return os.path.join('metadata', appid, 'signatures', vercode)
2085 return os.path.join('metadata', appid, 'signatures')
2088 def metadata_find_signing_files(appid, vercode):
2089 """Gets a list of singed manifests and signatures.
2091 :param appid: app id string
2092 :param vercode: app version code
2093 :returns: a list of triplets for each signing key with following paths:
2094 (signature_file, singed_file, manifest_file)
2097 sigdir = metadata_get_sigdir(appid, vercode)
2098 sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
2099 glob.glob(os.path.join(sigdir, '*.EC')) + \
2100 glob.glob(os.path.join(sigdir, '*.RSA'))
2101 extre = re.compile('(\.DSA|\.EC|\.RSA)$')
2103 sf = extre.sub('.SF', sig)
2104 if os.path.isfile(sf):
2105 mf = os.path.join(sigdir, 'MANIFEST.MF')
2106 if os.path.isfile(mf):
2107 ret.append((sig, sf, mf))
2111 def metadata_find_developer_signing_files(appid, vercode):
2112 """Get developer signature files for specified app from metadata.
2114 :returns: A triplet of paths for signing files from metadata:
2115 (signature_file, singed_file, manifest_file)
2117 allsigningfiles = metadata_find_signing_files(appid, vercode)
2118 if allsigningfiles and len(allsigningfiles) == 1:
2119 return allsigningfiles[0]
2124 def apk_strip_signatures(signed_apk, strip_manifest=False):
2125 """Removes signatures from APK.
2127 :param signed_apk: path to apk file.
2128 :param strip_manifest: when set to True also the manifest file will
2129 be removed from the APK.
2131 with tempfile.TemporaryDirectory() as tmpdir:
2132 tmp_apk = os.path.join(tmpdir, 'tmp.apk')
2133 os.rename(signed_apk, tmp_apk)
2134 with ZipFile(tmp_apk, 'r') as in_apk:
2135 with ZipFile(signed_apk, 'w') as out_apk:
2136 for f in in_apk.infolist():
2137 if not apk_sigfile.match(f.filename):
2139 if f.filename != 'META-INF/MANIFEST.MF':
2140 buf = in_apk.read(f.filename)
2141 out_apk.writestr(f.filename, buf)
2143 buf = in_apk.read(f.filename)
2144 out_apk.writestr(f.filename, buf)
2147 def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
2148 """Implats a signature from out metadata into an APK.
2150 Note: this changes there supplied APK in place. So copy it if you
2151 need the original to be preserved.
2153 :param apkpath: location of the apk
2155 # get list of available signature files in metadata
2156 with tempfile.TemporaryDirectory() as tmpdir:
2157 # orig_apk = os.path.join(tmpdir, 'orig.apk')
2158 # os.rename(apkpath, orig_apk)
2159 apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
2160 with ZipFile(apkpath, 'r') as in_apk:
2161 with ZipFile(apkwithnewsig, 'w') as out_apk:
2162 for sig_file in [signaturefile, signedfile, manifest]:
2163 out_apk.write(sig_file, arcname='META-INF/' +
2164 os.path.basename(sig_file))
2165 for f in in_apk.infolist():
2166 if not apk_sigfile.match(f.filename):
2167 if f.filename != 'META-INF/MANIFEST.MF':
2168 buf = in_apk.read(f.filename)
2169 out_apk.writestr(f.filename, buf)
2171 p = SdkToolsPopen(['zipalign', '-v', '4', apkwithnewsig, apkpath])
2172 if p.returncode != 0:
2173 raise BuildException("Failed to align application")
2176 def apk_extract_signatures(apkpath, outdir, manifest=True):
2177 """Extracts a signature files from APK and puts them into target directory.
2179 :param apkpath: location of the apk
2180 :param outdir: folder where the extracted signature files will be stored
2181 :param manifest: (optionally) disable extracting manifest file
2183 with ZipFile(apkpath, 'r') as in_apk:
2184 for f in in_apk.infolist():
2185 if apk_sigfile.match(f.filename) or \
2186 (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2187 newpath = os.path.join(outdir, os.path.basename(f.filename))
2188 with open(newpath, 'wb') as out_file:
2189 out_file.write(in_apk.read(f.filename))
2192 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2193 """Verify that two apks are the same
2195 One of the inputs is signed, the other is unsigned. The signature metadata
2196 is transferred from the signed to the unsigned apk, and then jarsigner is
2197 used to verify that the signature from the signed apk is also varlid for
2198 the unsigned one. If the APK given as unsigned actually does have a
2199 signature, it will be stripped out and ignored.
2201 There are two SHA1 git commit IDs that fdroidserver includes in the builds
2202 it makes: fdroidserverid and buildserverid. Originally, these were inserted
2203 into AndroidManifest.xml, but that makes the build not reproducible. So
2204 instead they are included as separate files in the APK's META-INF/ folder.
2205 If those files exist in the signed APK, they will be part of the signature
2206 and need to also be included in the unsigned APK for it to validate.
2208 :param signed_apk: Path to a signed apk file
2209 :param unsigned_apk: Path to an unsigned apk file expected to match it
2210 :param tmp_dir: Path to directory for temporary files
2211 :returns: None if the verification is successful, otherwise a string
2212 describing what went wrong.
2215 if not os.path.isfile(signed_apk):
2216 return 'can not verify: file does not exists: {}'.format(signed_apk)
2218 if not os.path.isfile(unsigned_apk):
2219 return 'can not verify: file does not exists: {}'.format(unsigned_apk)
2221 with ZipFile(signed_apk, 'r') as signed:
2222 meta_inf_files = ['META-INF/MANIFEST.MF']
2223 for f in signed.namelist():
2224 if apk_sigfile.match(f) \
2225 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2226 meta_inf_files.append(f)
2227 if len(meta_inf_files) < 3:
2228 return "Signature files missing from {0}".format(signed_apk)
2230 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2231 with ZipFile(unsigned_apk, 'r') as unsigned:
2232 # only read the signature from the signed APK, everything else from unsigned
2233 with ZipFile(tmp_apk, 'w') as tmp:
2234 for filename in meta_inf_files:
2235 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2236 for info in unsigned.infolist():
2237 if info.filename in meta_inf_files:
2238 logging.warning('Ignoring %s from %s',
2239 info.filename, unsigned_apk)
2241 if info.filename in tmp.namelist():
2242 return "duplicate filename found: " + info.filename
2243 tmp.writestr(info, unsigned.read(info.filename))
2245 verified = verify_apk_signature(tmp_apk)
2248 logging.info("...NOT verified - {0}".format(tmp_apk))
2249 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2250 os.path.dirname(unsigned_apk))
2252 logging.info("...successfully verified")
2256 def verify_jar_signature(jar):
2257 """Verifies the signature of a given JAR file.
2259 jarsigner is very shitty: unsigned JARs pass as "verified"! So
2260 this has to turn on -strict then check for result 4, since this
2261 does not expect the signature to be from a CA-signed certificate.
2263 :raises: VerificationException() if the JAR's signature could not be verified
2267 if subprocess.call([config['jarsigner'], '-strict', '-verify', jar]) != 4:
2268 raise VerificationException(_("The repository's index could not be verified."))
2271 def verify_apk_signature(apk, min_sdk_version=None):
2272 """verify the signature on an APK
2274 Try to use apksigner whenever possible since jarsigner is very
2275 shitty: unsigned APKs pass as "verified"! Warning, this does
2276 not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2278 :returns: boolean whether the APK was verified
2280 if set_command_in_config('apksigner'):
2281 args = [config['apksigner'], 'verify']
2283 args += ['--min-sdk-version=' + min_sdk_version]
2284 return subprocess.call(args + [apk]) == 0
2286 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2288 verify_jar_signature(apk)
2295 def verify_old_apk_signature(apk):
2296 """verify the signature on an archived APK, supporting deprecated algorithms
2298 F-Droid aims to keep every single binary that it ever published. Therefore,
2299 it needs to be able to verify APK signatures that include deprecated/removed
2300 algorithms. For example, jarsigner treats an MD5 signature as unsigned.
2302 jarsigner passes unsigned APKs as "verified"! So this has to turn
2303 on -strict then check for result 4.
2305 :returns: boolean whether the APK was verified
2308 _java_security = os.path.join(os.getcwd(), '.java.security')
2309 with open(_java_security, 'w') as fp:
2310 fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2312 return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2313 '-strict', '-verify', apk]) == 4
2316 apk_badchars = re.compile('''[/ :;'"]''')
2319 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2322 Returns None if the apk content is the same (apart from the signing key),
2323 otherwise a string describing what's different, or what went wrong when
2324 trying to do the comparison.
2330 absapk1 = os.path.abspath(apk1)
2331 absapk2 = os.path.abspath(apk2)
2333 if set_command_in_config('diffoscope'):
2334 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2335 htmlfile = logfilename + '.diffoscope.html'
2336 textfile = logfilename + '.diffoscope.txt'
2337 if subprocess.call([config['diffoscope'],
2338 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2339 '--html', htmlfile, '--text', textfile,
2340 absapk1, absapk2]) != 0:
2341 return("Failed to unpack " + apk1)
2343 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2344 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2345 for d in [apk1dir, apk2dir]:
2346 if os.path.exists(d):
2349 os.mkdir(os.path.join(d, 'jar-xf'))
2351 if subprocess.call(['jar', 'xf',
2352 os.path.abspath(apk1)],
2353 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2354 return("Failed to unpack " + apk1)
2355 if subprocess.call(['jar', 'xf',
2356 os.path.abspath(apk2)],
2357 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2358 return("Failed to unpack " + apk2)
2360 if set_command_in_config('apktool'):
2361 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2363 return("Failed to unpack " + apk1)
2364 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2366 return("Failed to unpack " + apk2)
2368 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2369 lines = p.output.splitlines()
2370 if len(lines) != 1 or 'META-INF' not in lines[0]:
2371 if set_command_in_config('meld'):
2372 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2373 return("Unexpected diff output - " + p.output)
2375 # since everything verifies, delete the comparison to keep cruft down
2376 shutil.rmtree(apk1dir)
2377 shutil.rmtree(apk2dir)
2379 # If we get here, it seems like they're the same!
2383 def set_command_in_config(command):
2384 '''Try to find specified command in the path, if it hasn't been
2385 manually set in config.py. If found, it is added to the config
2386 dict. The return value says whether the command is available.
2389 if command in config:
2392 tmp = find_command(command)
2394 config[command] = tmp
2399 def find_command(command):
2400 '''find the full path of a command, or None if it can't be found in the PATH'''
2403 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2405 fpath, fname = os.path.split(command)
2410 for path in os.environ["PATH"].split(os.pathsep):
2411 path = path.strip('"')
2412 exe_file = os.path.join(path, command)
2413 if is_exe(exe_file):
2420 '''generate a random password for when generating keys'''
2421 h = hashlib.sha256()
2422 h.update(os.urandom(16)) # salt
2423 h.update(socket.getfqdn().encode('utf-8'))
2424 passwd = base64.b64encode(h.digest()).strip()
2425 return passwd.decode('utf-8')
2428 def genkeystore(localconfig):
2430 Generate a new key with password provided in :param localconfig and add it to new keystore
2431 :return: hexed public key, public key fingerprint
2433 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2434 keystoredir = os.path.dirname(localconfig['keystore'])
2435 if keystoredir is None or keystoredir == '':
2436 keystoredir = os.path.join(os.getcwd(), keystoredir)
2437 if not os.path.exists(keystoredir):
2438 os.makedirs(keystoredir, mode=0o700)
2441 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2442 'FDROID_KEY_PASS': localconfig['keypass'],
2444 p = FDroidPopen([config['keytool'], '-genkey',
2445 '-keystore', localconfig['keystore'],
2446 '-alias', localconfig['repo_keyalias'],
2447 '-keyalg', 'RSA', '-keysize', '4096',
2448 '-sigalg', 'SHA256withRSA',
2449 '-validity', '10000',
2450 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2451 '-keypass:env', 'FDROID_KEY_PASS',
2452 '-dname', localconfig['keydname']], envs=env_vars)
2453 if p.returncode != 0:
2454 raise BuildException("Failed to generate key", p.output)
2455 os.chmod(localconfig['keystore'], 0o0600)
2456 if not options.quiet:
2457 # now show the lovely key that was just generated
2458 p = FDroidPopen([config['keytool'], '-list', '-v',
2459 '-keystore', localconfig['keystore'],
2460 '-alias', localconfig['repo_keyalias'],
2461 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2462 logging.info(p.output.strip() + '\n\n')
2463 # get the public key
2464 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2465 '-keystore', localconfig['keystore'],
2466 '-alias', localconfig['repo_keyalias'],
2467 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2468 + config['smartcardoptions'],
2469 envs=env_vars, output=False, stderr_to_stdout=False)
2470 if p.returncode != 0 or len(p.output) < 20:
2471 raise BuildException("Failed to get public key", p.output)
2473 fingerprint = get_cert_fingerprint(pubkey)
2474 return hexlify(pubkey), fingerprint
2477 def get_cert_fingerprint(pubkey):
2479 Generate a certificate fingerprint the same way keytool does it
2480 (but with slightly different formatting)
2482 digest = hashlib.sha256(pubkey).digest()
2483 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2484 return " ".join(ret)
2487 def get_certificate(certificate_file):
2489 Extracts a certificate from the given file.
2490 :param certificate_file: file bytes (as string) representing the certificate
2491 :return: A binary representation of the certificate's public key, or None in case of error
2493 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2494 if content.getComponentByName('contentType') != rfc2315.signedData:
2496 content = decoder.decode(content.getComponentByName('content'),
2497 asn1Spec=rfc2315.SignedData())[0]
2499 certificates = content.getComponentByName('certificates')
2500 cert = certificates[0].getComponentByName('certificate')
2502 logging.error("Certificates not found.")
2504 return encoder.encode(cert)
2507 def write_to_config(thisconfig, key, value=None, config_file=None):
2508 '''write a key/value to the local config.py
2510 NOTE: only supports writing string variables.
2512 :param thisconfig: config dictionary
2513 :param key: variable name in config.py to be overwritten/added
2514 :param value: optional value to be written, instead of fetched
2515 from 'thisconfig' dictionary.
2518 origkey = key + '_orig'
2519 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2520 cfg = config_file if config_file else 'config.py'
2522 # load config file, create one if it doesn't exist
2523 if not os.path.exists(cfg):
2524 open(cfg, 'a').close()
2525 logging.info("Creating empty " + cfg)
2526 with open(cfg, 'r', encoding="utf-8") as f:
2527 lines = f.readlines()
2529 # make sure the file ends with a carraige return
2531 if not lines[-1].endswith('\n'):
2534 # regex for finding and replacing python string variable
2535 # definitions/initializations
2536 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2537 repl = key + ' = "' + value + '"'
2538 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2539 repl2 = key + " = '" + value + "'"
2541 # If we replaced this line once, we make sure won't be a
2542 # second instance of this line for this key in the document.
2545 with open(cfg, 'w', encoding="utf-8") as f:
2547 if pattern.match(line) or pattern2.match(line):
2549 line = pattern.sub(repl, line)
2550 line = pattern2.sub(repl2, line)
2561 def parse_xml(path):
2562 return XMLElementTree.parse(path).getroot()
2565 def string_is_integer(string):
2573 def get_per_app_repos():
2574 '''per-app repos are dirs named with the packageName of a single app'''
2576 # Android packageNames are Java packages, they may contain uppercase or
2577 # lowercase letters ('A' through 'Z'), numbers, and underscores
2578 # ('_'). However, individual package name parts may only start with
2579 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2580 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2583 for root, dirs, files in os.walk(os.getcwd()):
2585 print('checking', root, 'for', d)
2586 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2587 # standard parts of an fdroid repo, so never packageNames
2590 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2596 def is_repo_file(filename):
2597 '''Whether the file in a repo is a build product to be delivered to users'''
2598 if isinstance(filename, str):
2599 filename = filename.encode('utf-8', errors="surrogateescape")
2600 return os.path.isfile(filename) \
2601 and not filename.endswith(b'.asc') \
2602 and not filename.endswith(b'.sig') \
2603 and os.path.basename(filename) not in [
2605 b'index_unsigned.jar',