3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
37 import xml.etree.ElementTree as XMLElementTree
39 from binascii import hexlify
40 from datetime import datetime
41 from distutils.version import LooseVersion
42 from queue import Queue
43 from zipfile import ZipFile
45 from pyasn1.codec.der import decoder, encoder
46 from pyasn1_modules import rfc2315
47 from pyasn1.error import PyAsn1Error
49 from distutils.util import strtobool
51 import fdroidserver.metadata
52 from fdroidserver.exception import FDroidException, VCSException, BuildException
53 from .asynchronousfilereader import AsynchronousFileReader
56 # A signature block file with a .DSA, .RSA, or .EC extension
57 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
59 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
68 'sdk_path': "$ANDROID_HOME",
73 'r12b': "$ANDROID_NDK",
78 'build_tools': "25.0.2",
79 'force_build_tools': False,
84 'accepted_formats': ['txt', 'yml'],
85 'sync_from_local_copy_dir': False,
86 'per_app_repos': False,
87 'make_current_version_link': True,
88 'current_version_name_source': 'Name',
89 'update_stats': False,
93 'stats_to_carbon': False,
95 'build_server_always': False,
96 'keystore': 'keystore.jks',
97 'smartcardoptions': [],
107 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
108 'repo_name': "My First FDroid Repo Demo",
109 'repo_icon': "fdroid-icon.png",
110 'repo_description': '''
111 This is a repository of apps to be used with FDroid. Applications in this
112 repository are either official binaries built by the original application
113 developers, or are binaries built from source by the admin of f-droid.org
114 using the tools on https://gitlab.com/u/fdroid.
120 def setup_global_opts(parser):
121 parser.add_argument("-v", "--verbose", action="store_true", default=False,
122 help="Spew out even more information than normal")
123 parser.add_argument("-q", "--quiet", action="store_true", default=False,
124 help="Restrict output to warnings and errors")
127 def fill_config_defaults(thisconfig):
128 for k, v in default_config.items():
129 if k not in thisconfig:
132 # Expand paths (~users and $vars)
133 def expand_path(path):
137 path = os.path.expanduser(path)
138 path = os.path.expandvars(path)
143 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
148 thisconfig[k + '_orig'] = v
150 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
151 if thisconfig['java_paths'] is None:
152 thisconfig['java_paths'] = dict()
154 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
155 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
156 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
157 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
158 if os.getenv('JAVA_HOME') is not None:
159 pathlist.append(os.getenv('JAVA_HOME'))
160 if os.getenv('PROGRAMFILES') is not None:
161 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
162 for d in sorted(pathlist):
163 if os.path.islink(d):
165 j = os.path.basename(d)
166 # the last one found will be the canonical one, so order appropriately
168 r'^1\.([6-9])\.0\.jdk$', # OSX
169 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
170 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
171 r'^jdk([6-9])-openjdk$', # Arch
172 r'^java-([6-9])-openjdk$', # Arch
173 r'^java-([6-9])-jdk$', # Arch (oracle)
174 r'^java-1\.([6-9])\.0-.*$', # RedHat
175 r'^java-([6-9])-oracle$', # Debian WebUpd8
176 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
177 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
179 m = re.match(regex, j)
182 for p in [d, os.path.join(d, 'Contents', 'Home')]:
183 if os.path.exists(os.path.join(p, 'bin', 'javac')):
184 thisconfig['java_paths'][m.group(1)] = p
186 for java_version in ('7', '8', '9'):
187 if java_version not in thisconfig['java_paths']:
189 java_home = thisconfig['java_paths'][java_version]
190 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
191 if os.path.exists(jarsigner):
192 thisconfig['jarsigner'] = jarsigner
193 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
194 break # Java7 is preferred, so quit if found
196 for k in ['ndk_paths', 'java_paths']:
202 thisconfig[k][k2] = exp
203 thisconfig[k][k2 + '_orig'] = v
206 def regsub_file(pattern, repl, path):
207 with open(path, 'rb') as f:
209 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
210 with open(path, 'wb') as f:
214 def read_config(opts, config_file='config.py'):
215 """Read the repository config
217 The config is read from config_file, which is in the current
218 directory when any of the repo management commands are used. If
219 there is a local metadata file in the git repo, then config.py is
220 not required, just use defaults.
223 global config, options
225 if config is not None:
232 if os.path.isfile(config_file):
233 logging.debug("Reading %s" % config_file)
234 with io.open(config_file, "rb") as f:
235 code = compile(f.read(), config_file, 'exec')
236 exec(code, None, config)
237 elif len(get_local_metadata_files()) == 0:
238 raise FDroidException("Missing config file - is this a repo directory?")
240 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
242 if not type(config[k]) in (str, list, tuple):
243 logging.warn('"' + k + '" will be in random order!'
244 + ' Use () or [] brackets if order is important!')
246 # smartcardoptions must be a list since its command line args for Popen
247 if 'smartcardoptions' in config:
248 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
249 elif 'keystore' in config and config['keystore'] == 'NONE':
250 # keystore='NONE' means use smartcard, these are required defaults
251 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
252 'SunPKCS11-OpenSC', '-providerClass',
253 'sun.security.pkcs11.SunPKCS11',
254 '-providerArg', 'opensc-fdroid.cfg']
256 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
257 st = os.stat(config_file)
258 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
259 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
261 fill_config_defaults(config)
263 for k in ["repo_description", "archive_description"]:
265 config[k] = clean_description(config[k])
267 if 'serverwebroot' in config:
268 if isinstance(config['serverwebroot'], str):
269 roots = [config['serverwebroot']]
270 elif all(isinstance(item, str) for item in config['serverwebroot']):
271 roots = config['serverwebroot']
273 raise TypeError('only accepts strings, lists, and tuples')
275 for rootstr in roots:
276 # since this is used with rsync, where trailing slashes have
277 # meaning, ensure there is always a trailing slash
278 if rootstr[-1] != '/':
280 rootlist.append(rootstr.replace('//', '/'))
281 config['serverwebroot'] = rootlist
283 if 'servergitmirrors' in config:
284 if isinstance(config['servergitmirrors'], str):
285 roots = [config['servergitmirrors']]
286 elif all(isinstance(item, str) for item in config['servergitmirrors']):
287 roots = config['servergitmirrors']
289 raise TypeError('only accepts strings, lists, and tuples')
290 config['servergitmirrors'] = roots
295 def find_sdk_tools_cmd(cmd):
296 '''find a working path to a tool from the Android SDK'''
299 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
300 # try to find a working path to this command, in all the recent possible paths
301 if 'build_tools' in config:
302 build_tools = os.path.join(config['sdk_path'], 'build-tools')
303 # if 'build_tools' was manually set and exists, check only that one
304 configed_build_tools = os.path.join(build_tools, config['build_tools'])
305 if os.path.exists(configed_build_tools):
306 tooldirs.append(configed_build_tools)
308 # no configed version, so hunt known paths for it
309 for f in sorted(os.listdir(build_tools), reverse=True):
310 if os.path.isdir(os.path.join(build_tools, f)):
311 tooldirs.append(os.path.join(build_tools, f))
312 tooldirs.append(build_tools)
313 sdk_tools = os.path.join(config['sdk_path'], 'tools')
314 if os.path.exists(sdk_tools):
315 tooldirs.append(sdk_tools)
316 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
317 if os.path.exists(sdk_platform_tools):
318 tooldirs.append(sdk_platform_tools)
319 tooldirs.append('/usr/bin')
321 path = os.path.join(d, cmd)
322 if os.path.isfile(path):
324 test_aapt_version(path)
326 # did not find the command, exit with error message
327 ensure_build_tools_exists(config)
330 def test_aapt_version(aapt):
331 '''Check whether the version of aapt is new enough'''
332 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
333 if output is None or output == '':
334 logging.error(aapt + ' failed to execute!')
336 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
341 # the Debian package has the version string like "v0.2-23.0.2"
342 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
343 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
345 logging.warning('Unknown version of aapt, might cause problems: ' + output)
348 def test_sdk_exists(thisconfig):
349 if 'sdk_path' not in thisconfig:
350 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
351 test_aapt_version(thisconfig['aapt'])
354 logging.error("'sdk_path' not set in config.py!")
356 if thisconfig['sdk_path'] == default_config['sdk_path']:
357 logging.error('No Android SDK found!')
358 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
359 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
361 if not os.path.exists(thisconfig['sdk_path']):
362 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
364 if not os.path.isdir(thisconfig['sdk_path']):
365 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
367 for d in ['build-tools', 'platform-tools', 'tools']:
368 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
369 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
370 thisconfig['sdk_path'], d))
375 def ensure_build_tools_exists(thisconfig):
376 if not test_sdk_exists(thisconfig):
377 raise FDroidException("Android SDK not found.")
378 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
379 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
380 if not os.path.isdir(versioned_build_tools):
381 raise FDroidException(
382 'Android Build Tools path "' + versioned_build_tools + '" does not exist!')
385 def get_local_metadata_files():
386 '''get any metadata files local to an app's source repo
388 This tries to ignore anything that does not count as app metdata,
389 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
392 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
395 def read_pkg_args(args, allow_vercodes=False):
397 Given the arguments in the form of multiple appid:[vc] strings, this returns
398 a dictionary with the set of vercodes specified for each package.
406 if allow_vercodes and ':' in p:
407 package, vercode = p.split(':')
409 package, vercode = p, None
410 if package not in vercodes:
411 vercodes[package] = [vercode] if vercode else []
413 elif vercode and vercode not in vercodes[package]:
414 vercodes[package] += [vercode] if vercode else []
419 def read_app_args(args, allapps, allow_vercodes=False):
421 On top of what read_pkg_args does, this returns the whole app metadata, but
422 limiting the builds list to the builds matching the vercodes specified.
425 vercodes = read_pkg_args(args, allow_vercodes)
431 for appid, app in allapps.items():
432 if appid in vercodes:
435 if len(apps) != len(vercodes):
438 logging.critical("No such package: %s" % p)
439 raise FDroidException("Found invalid app ids in arguments")
441 raise FDroidException("No packages specified")
444 for appid, app in apps.items():
448 app.builds = [b for b in app.builds if b.versionCode in vc]
449 if len(app.builds) != len(vercodes[appid]):
451 allvcs = [b.versionCode for b in app.builds]
452 for v in vercodes[appid]:
454 logging.critical("No such vercode %s for app %s" % (v, appid))
457 raise FDroidException("Found invalid vercodes for some apps")
462 def get_extension(filename):
463 base, ext = os.path.splitext(filename)
466 return base, ext.lower()[1:]
469 def has_extension(filename, ext):
470 _, f_ext = get_extension(filename)
474 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
477 def clean_description(description):
478 'Remove unneeded newlines and spaces from a block of description text'
480 # this is split up by paragraph to make removing the newlines easier
481 for paragraph in re.split(r'\n\n', description):
482 paragraph = re.sub('\r', '', paragraph)
483 paragraph = re.sub('\n', ' ', paragraph)
484 paragraph = re.sub(' {2,}', ' ', paragraph)
485 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
486 returnstring += paragraph + '\n\n'
487 return returnstring.rstrip('\n')
490 def publishednameinfo(filename):
491 filename = os.path.basename(filename)
492 m = publish_name_regex.match(filename)
494 result = (m.group(1), m.group(2))
495 except AttributeError:
496 raise FDroidException("Invalid name for published file: %s" % filename)
500 def get_release_filename(app, build):
502 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
504 return "%s_%s.apk" % (app.id, build.versionCode)
507 def get_toolsversion_logname(app, build):
508 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
511 def getsrcname(app, build):
512 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
524 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
527 def get_build_dir(app):
528 '''get the dir that this app will be built in'''
530 if app.RepoType == 'srclib':
531 return os.path.join('build', 'srclib', app.Repo)
533 return os.path.join('build', app.id)
537 '''checkout code from VCS and return instance of vcs and the build dir'''
538 build_dir = get_build_dir(app)
540 # Set up vcs interface and make sure we have the latest code...
541 logging.debug("Getting {0} vcs interface for {1}"
542 .format(app.RepoType, app.Repo))
543 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
547 vcs = getvcs(app.RepoType, remote, build_dir)
549 return vcs, build_dir
552 def getvcs(vcstype, remote, local):
554 return vcs_git(remote, local)
555 if vcstype == 'git-svn':
556 return vcs_gitsvn(remote, local)
558 return vcs_hg(remote, local)
560 return vcs_bzr(remote, local)
561 if vcstype == 'srclib':
562 if local != os.path.join('build', 'srclib', remote):
563 raise VCSException("Error: srclib paths are hard-coded!")
564 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
566 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
567 raise VCSException("Invalid vcs type " + vcstype)
570 def getsrclibvcs(name):
571 if name not in fdroidserver.metadata.srclibs:
572 raise VCSException("Missing srclib " + name)
573 return fdroidserver.metadata.srclibs[name]['Repo Type']
578 def __init__(self, remote, local):
580 # svn, git-svn and bzr may require auth
582 if self.repotype() in ('git-svn', 'bzr'):
584 if self.repotype == 'git-svn':
585 raise VCSException("Authentication is not supported for git-svn")
586 self.username, remote = remote.split('@')
587 if ':' not in self.username:
588 raise VCSException("Password required with username")
589 self.username, self.password = self.username.split(':')
593 self.clone_failed = False
594 self.refreshed = False
600 # Take the local repository to a clean version of the given revision, which
601 # is specificed in the VCS's native format. Beforehand, the repository can
602 # be dirty, or even non-existent. If the repository does already exist
603 # locally, it will be updated from the origin, but only once in the
604 # lifetime of the vcs object.
605 # None is acceptable for 'rev' if you know you are cloning a clean copy of
606 # the repo - otherwise it must specify a valid revision.
607 def gotorevision(self, rev, refresh=True):
609 if self.clone_failed:
610 raise VCSException("Downloading the repository already failed once, not trying again.")
612 # The .fdroidvcs-id file for a repo tells us what VCS type
613 # and remote that directory was created from, allowing us to drop it
614 # automatically if either of those things changes.
615 fdpath = os.path.join(self.local, '..',
616 '.fdroidvcs-' + os.path.basename(self.local))
617 fdpath = os.path.normpath(fdpath)
618 cdata = self.repotype() + ' ' + self.remote
621 if os.path.exists(self.local):
622 if os.path.exists(fdpath):
623 with open(fdpath, 'r') as f:
624 fsdata = f.read().strip()
629 logging.info("Repository details for %s changed - deleting" % (
633 logging.info("Repository details for %s missing - deleting" % (
636 shutil.rmtree(self.local)
640 self.refreshed = True
643 self.gotorevisionx(rev)
644 except FDroidException as e:
647 # If necessary, write the .fdroidvcs file.
648 if writeback and not self.clone_failed:
649 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
650 with open(fdpath, 'w+') as f:
656 # Derived classes need to implement this. It's called once basic checking
657 # has been performend.
658 def gotorevisionx(self, rev): # pylint: disable=unused-argument
659 raise VCSException("This VCS type doesn't define gotorevisionx")
661 # Initialise and update submodules
662 def initsubmodules(self):
663 raise VCSException('Submodules not supported for this vcs type')
665 # Get a list of all known tags
667 if not self._gettags:
668 raise VCSException('gettags not supported for this vcs type')
670 for tag in self._gettags():
671 if re.match('[-A-Za-z0-9_. /]+$', tag):
675 # Get a list of all the known tags, sorted from newest to oldest
676 def latesttags(self):
677 raise VCSException('latesttags not supported for this vcs type')
679 # Get current commit reference (hash, revision, etc)
681 raise VCSException('getref not supported for this vcs type')
683 # Returns the srclib (name, path) used in setting up the current
694 # If the local directory exists, but is somehow not a git repository, git
695 # will traverse up the directory tree until it finds one that is (i.e.
696 # fdroidserver) and then we'll proceed to destroy it! This is called as
699 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
700 result = p.output.rstrip()
701 if not result.endswith(self.local):
702 raise VCSException('Repository mismatch')
704 def gotorevisionx(self, rev):
705 if not os.path.exists(self.local):
707 p = FDroidPopen(['git', 'clone', self.remote, self.local])
708 if p.returncode != 0:
709 self.clone_failed = True
710 raise VCSException("Git clone failed", p.output)
714 # Discard any working tree changes
715 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
716 'git', 'reset', '--hard'], cwd=self.local, output=False)
717 if p.returncode != 0:
718 raise VCSException("Git reset failed", p.output)
719 # Remove untracked files now, in case they're tracked in the target
720 # revision (it happens!)
721 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
722 'git', 'clean', '-dffx'], cwd=self.local, output=False)
723 if p.returncode != 0:
724 raise VCSException("Git clean failed", p.output)
725 if not self.refreshed:
726 # Get latest commits and tags from remote
727 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
728 if p.returncode != 0:
729 raise VCSException("Git fetch failed", p.output)
730 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
731 if p.returncode != 0:
732 raise VCSException("Git fetch failed", p.output)
733 # Recreate origin/HEAD as git clone would do it, in case it disappeared
734 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
735 if p.returncode != 0:
736 lines = p.output.splitlines()
737 if 'Multiple remote HEAD branches' not in lines[0]:
738 raise VCSException("Git remote set-head failed", p.output)
739 branch = lines[1].split(' ')[-1]
740 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
741 if p2.returncode != 0:
742 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
743 self.refreshed = True
744 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
745 # a github repo. Most of the time this is the same as origin/master.
746 rev = rev or 'origin/HEAD'
747 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
748 if p.returncode != 0:
749 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
750 # Get rid of any uncontrolled files left behind
751 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
752 if p.returncode != 0:
753 raise VCSException("Git clean failed", p.output)
755 def initsubmodules(self):
757 submfile = os.path.join(self.local, '.gitmodules')
758 if not os.path.isfile(submfile):
759 raise VCSException("No git submodules available")
761 # fix submodules not accessible without an account and public key auth
762 with open(submfile, 'r') as f:
763 lines = f.readlines()
764 with open(submfile, 'w') as f:
766 if 'git@github.com' in line:
767 line = line.replace('git@github.com:', 'https://github.com/')
768 if 'git@gitlab.com' in line:
769 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
772 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
773 if p.returncode != 0:
774 raise VCSException("Git submodule sync failed", p.output)
775 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
776 if p.returncode != 0:
777 raise VCSException("Git submodule update failed", p.output)
781 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
782 return p.output.splitlines()
784 tag_format = re.compile(r'tag: ([^),]*)')
786 def latesttags(self):
788 p = FDroidPopen(['git', 'log', '--tags',
789 '--simplify-by-decoration', '--pretty=format:%d'],
790 cwd=self.local, output=False)
792 for line in p.output.splitlines():
793 for tag in self.tag_format.findall(line):
798 class vcs_gitsvn(vcs):
803 # If the local directory exists, but is somehow not a git repository, git
804 # will traverse up the directory tree until it finds one that is (i.e.
805 # fdroidserver) and then we'll proceed to destory it! This is called as
808 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
809 result = p.output.rstrip()
810 if not result.endswith(self.local):
811 raise VCSException('Repository mismatch')
813 def gotorevisionx(self, rev):
814 if not os.path.exists(self.local):
816 gitsvn_args = ['git', 'svn', 'clone']
817 if ';' in self.remote:
818 remote_split = self.remote.split(';')
819 for i in remote_split[1:]:
820 if i.startswith('trunk='):
821 gitsvn_args.extend(['-T', i[6:]])
822 elif i.startswith('tags='):
823 gitsvn_args.extend(['-t', i[5:]])
824 elif i.startswith('branches='):
825 gitsvn_args.extend(['-b', i[9:]])
826 gitsvn_args.extend([remote_split[0], self.local])
827 p = FDroidPopen(gitsvn_args, output=False)
828 if p.returncode != 0:
829 self.clone_failed = True
830 raise VCSException("Git svn clone failed", p.output)
832 gitsvn_args.extend([self.remote, self.local])
833 p = FDroidPopen(gitsvn_args, output=False)
834 if p.returncode != 0:
835 self.clone_failed = True
836 raise VCSException("Git svn clone failed", p.output)
840 # Discard any working tree changes
841 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("Git reset failed", p.output)
844 # Remove untracked files now, in case they're tracked in the target
845 # revision (it happens!)
846 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
847 if p.returncode != 0:
848 raise VCSException("Git clean failed", p.output)
849 if not self.refreshed:
850 # Get new commits, branches and tags from repo
851 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
852 if p.returncode != 0:
853 raise VCSException("Git svn fetch failed")
854 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
855 if p.returncode != 0:
856 raise VCSException("Git svn rebase failed", p.output)
857 self.refreshed = True
859 rev = rev or 'master'
861 nospaces_rev = rev.replace(' ', '%20')
862 # Try finding a svn tag
863 for treeish in ['origin/', '']:
864 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
865 if p.returncode == 0:
867 if p.returncode != 0:
868 # No tag found, normal svn rev translation
869 # Translate svn rev into git format
870 rev_split = rev.split('/')
873 for treeish in ['origin/', '']:
874 if len(rev_split) > 1:
875 treeish += rev_split[0]
876 svn_rev = rev_split[1]
879 # if no branch is specified, then assume trunk (i.e. 'master' branch):
883 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
885 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
886 git_rev = p.output.rstrip()
888 if p.returncode == 0 and git_rev:
891 if p.returncode != 0 or not git_rev:
892 # Try a plain git checkout as a last resort
893 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
894 if p.returncode != 0:
895 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
897 # Check out the git rev equivalent to the svn rev
898 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
899 if p.returncode != 0:
900 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
902 # Get rid of any uncontrolled files left behind
903 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
904 if p.returncode != 0:
905 raise VCSException("Git clean failed", p.output)
909 for treeish in ['origin/', '']:
910 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
916 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
917 if p.returncode != 0:
919 return p.output.strip()
927 def gotorevisionx(self, rev):
928 if not os.path.exists(self.local):
929 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
930 if p.returncode != 0:
931 self.clone_failed = True
932 raise VCSException("Hg clone failed", p.output)
934 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
935 if p.returncode != 0:
936 raise VCSException("Hg status failed", p.output)
937 for line in p.output.splitlines():
938 if not line.startswith('? '):
939 raise VCSException("Unexpected output from hg status -uS: " + line)
940 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
941 if not self.refreshed:
942 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
943 if p.returncode != 0:
944 raise VCSException("Hg pull failed", p.output)
945 self.refreshed = True
947 rev = rev or 'default'
950 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
951 if p.returncode != 0:
952 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
953 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
954 # Also delete untracked files, we have to enable purge extension for that:
955 if "'purge' is provided by the following extension" in p.output:
956 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
957 myfile.write("\n[extensions]\nhgext.purge=\n")
958 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
959 if p.returncode != 0:
960 raise VCSException("HG purge failed", p.output)
961 elif p.returncode != 0:
962 raise VCSException("HG purge failed", p.output)
965 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
966 return p.output.splitlines()[1:]
974 def gotorevisionx(self, rev):
975 if not os.path.exists(self.local):
976 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
977 if p.returncode != 0:
978 self.clone_failed = True
979 raise VCSException("Bzr branch failed", p.output)
981 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
982 if p.returncode != 0:
983 raise VCSException("Bzr revert failed", p.output)
984 if not self.refreshed:
985 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
986 if p.returncode != 0:
987 raise VCSException("Bzr update failed", p.output)
988 self.refreshed = True
990 revargs = list(['-r', rev] if rev else [])
991 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
992 if p.returncode != 0:
993 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
996 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
997 return [tag.split(' ')[0].strip() for tag in
998 p.output.splitlines()]
1001 def unescape_string(string):
1004 if string[0] == '"' and string[-1] == '"':
1007 return string.replace("\\'", "'")
1010 def retrieve_string(app_dir, string, xmlfiles=None):
1012 if not string.startswith('@string/'):
1013 return unescape_string(string)
1015 if xmlfiles is None:
1018 os.path.join(app_dir, 'res'),
1019 os.path.join(app_dir, 'src', 'main', 'res'),
1021 for r, d, f in os.walk(res_dir):
1022 if os.path.basename(r) == 'values':
1023 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1025 name = string[len('@string/'):]
1027 def element_content(element):
1028 if element.text is None:
1030 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1031 return s.decode('utf-8').strip()
1033 for path in xmlfiles:
1034 if not os.path.isfile(path):
1036 xml = parse_xml(path)
1037 element = xml.find('string[@name="' + name + '"]')
1038 if element is not None:
1039 content = element_content(element)
1040 return retrieve_string(app_dir, content, xmlfiles)
1045 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1046 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1049 def manifest_paths(app_dir, flavours):
1050 '''Return list of existing files that will be used to find the highest vercode'''
1052 possible_manifests = \
1053 [os.path.join(app_dir, 'AndroidManifest.xml'),
1054 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1055 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1056 os.path.join(app_dir, 'build.gradle')]
1058 for flavour in flavours:
1059 if flavour == 'yes':
1061 possible_manifests.append(
1062 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1064 return [path for path in possible_manifests if os.path.isfile(path)]
1067 def fetch_real_name(app_dir, flavours):
1068 '''Retrieve the package name. Returns the name, or None if not found.'''
1069 for path in manifest_paths(app_dir, flavours):
1070 if not has_extension(path, 'xml') or not os.path.isfile(path):
1072 logging.debug("fetch_real_name: Checking manifest at " + path)
1073 xml = parse_xml(path)
1074 app = xml.find('application')
1077 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1079 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1080 result = retrieve_string_singleline(app_dir, label)
1082 result = result.strip()
1087 def get_library_references(root_dir):
1089 proppath = os.path.join(root_dir, 'project.properties')
1090 if not os.path.isfile(proppath):
1092 with open(proppath, 'r', encoding='iso-8859-1') as f:
1094 if not line.startswith('android.library.reference.'):
1096 path = line.split('=')[1].strip()
1097 relpath = os.path.join(root_dir, path)
1098 if not os.path.isdir(relpath):
1100 logging.debug("Found subproject at %s" % path)
1101 libraries.append(path)
1105 def ant_subprojects(root_dir):
1106 subprojects = get_library_references(root_dir)
1107 for subpath in subprojects:
1108 subrelpath = os.path.join(root_dir, subpath)
1109 for p in get_library_references(subrelpath):
1110 relp = os.path.normpath(os.path.join(subpath, p))
1111 if relp not in subprojects:
1112 subprojects.insert(0, relp)
1116 def remove_debuggable_flags(root_dir):
1117 # Remove forced debuggable flags
1118 logging.debug("Removing debuggable flags from %s" % root_dir)
1119 for root, dirs, files in os.walk(root_dir):
1120 if 'AndroidManifest.xml' in files:
1121 regsub_file(r'android:debuggable="[^"]*"',
1123 os.path.join(root, 'AndroidManifest.xml'))
1126 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1127 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1128 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1131 def app_matches_packagename(app, package):
1134 appid = app.UpdateCheckName or app.id
1135 if appid is None or appid == "Ignore":
1137 return appid == package
1140 def parse_androidmanifests(paths, app):
1142 Extract some information from the AndroidManifest.xml at the given path.
1143 Returns (version, vercode, package), any or all of which might be None.
1144 All values returned are strings.
1147 ignoreversions = app.UpdateCheckIgnore
1148 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1151 return (None, None, None)
1159 if not os.path.isfile(path):
1162 logging.debug("Parsing manifest at {0}".format(path))
1167 if has_extension(path, 'gradle'):
1168 with open(path, 'r') as f:
1170 if gradle_comment.match(line):
1172 # Grab first occurence of each to avoid running into
1173 # alternative flavours and builds.
1175 matches = psearch_g(line)
1177 s = matches.group(2)
1178 if app_matches_packagename(app, s):
1181 matches = vnsearch_g(line)
1183 version = matches.group(2)
1185 matches = vcsearch_g(line)
1187 vercode = matches.group(1)
1190 xml = parse_xml(path)
1191 if "package" in xml.attrib:
1192 s = xml.attrib["package"]
1193 if app_matches_packagename(app, s):
1195 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1196 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1197 base_dir = os.path.dirname(path)
1198 version = retrieve_string_singleline(base_dir, version)
1199 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1200 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1201 if string_is_integer(a):
1204 logging.warning("Problem with xml at {0}".format(path))
1206 # Remember package name, may be defined separately from version+vercode
1208 package = max_package
1210 logging.debug("..got package={0}, version={1}, vercode={2}"
1211 .format(package, version, vercode))
1213 # Always grab the package name and version name in case they are not
1214 # together with the highest version code
1215 if max_package is None and package is not None:
1216 max_package = package
1217 if max_version is None and version is not None:
1218 max_version = version
1220 if vercode is not None \
1221 and (max_vercode is None or vercode > max_vercode):
1222 if not ignoresearch or not ignoresearch(version):
1223 if version is not None:
1224 max_version = version
1225 if vercode is not None:
1226 max_vercode = vercode
1227 if package is not None:
1228 max_package = package
1230 max_version = "Ignore"
1232 if max_version is None:
1233 max_version = "Unknown"
1235 if max_package and not is_valid_package_name(max_package):
1236 raise FDroidException("Invalid package name {0}".format(max_package))
1238 return (max_version, max_vercode, max_package)
1241 def is_valid_package_name(name):
1242 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1245 # Get the specified source library.
1246 # Returns the path to it. Normally this is the path to be used when referencing
1247 # it, which may be a subdirectory of the actual project. If you want the base
1248 # directory of the project, pass 'basepath=True'.
1249 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1250 raw=False, prepare=True, preponly=False, refresh=True,
1259 name, ref = spec.split('@')
1261 number, name = name.split(':', 1)
1263 name, subdir = name.split('/', 1)
1265 if name not in fdroidserver.metadata.srclibs:
1266 raise VCSException('srclib ' + name + ' not found.')
1268 srclib = fdroidserver.metadata.srclibs[name]
1270 sdir = os.path.join(srclib_dir, name)
1273 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1274 vcs.srclib = (name, number, sdir)
1276 vcs.gotorevision(ref, refresh)
1283 libdir = os.path.join(sdir, subdir)
1284 elif srclib["Subdir"]:
1285 for subdir in srclib["Subdir"]:
1286 libdir_candidate = os.path.join(sdir, subdir)
1287 if os.path.exists(libdir_candidate):
1288 libdir = libdir_candidate
1294 remove_signing_keys(sdir)
1295 remove_debuggable_flags(sdir)
1299 if srclib["Prepare"]:
1300 cmd = replace_config_vars(srclib["Prepare"], build)
1302 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1303 if p.returncode != 0:
1304 raise BuildException("Error running prepare command for srclib %s"
1310 return (name, number, libdir)
1313 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1316 # Prepare the source code for a particular build
1317 # 'vcs' - the appropriate vcs object for the application
1318 # 'app' - the application details from the metadata
1319 # 'build' - the build details from the metadata
1320 # 'build_dir' - the path to the build directory, usually
1322 # 'srclib_dir' - the path to the source libraries directory, usually
1324 # 'extlib_dir' - the path to the external libraries directory, usually
1326 # Returns the (root, srclibpaths) where:
1327 # 'root' is the root directory, which may be the same as 'build_dir' or may
1328 # be a subdirectory of it.
1329 # 'srclibpaths' is information on the srclibs being used
1330 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1332 # Optionally, the actual app source can be in a subdirectory
1334 root_dir = os.path.join(build_dir, build.subdir)
1336 root_dir = build_dir
1338 # Get a working copy of the right revision
1339 logging.info("Getting source for revision " + build.commit)
1340 vcs.gotorevision(build.commit, refresh)
1342 # Initialise submodules if required
1343 if build.submodules:
1344 logging.info("Initialising submodules")
1345 vcs.initsubmodules()
1347 # Check that a subdir (if we're using one) exists. This has to happen
1348 # after the checkout, since it might not exist elsewhere
1349 if not os.path.exists(root_dir):
1350 raise BuildException('Missing subdir ' + root_dir)
1352 # Run an init command if one is required
1354 cmd = replace_config_vars(build.init, build)
1355 logging.info("Running 'init' commands in %s" % root_dir)
1357 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1358 if p.returncode != 0:
1359 raise BuildException("Error running init command for %s:%s" %
1360 (app.id, build.versionName), p.output)
1362 # Apply patches if any
1364 logging.info("Applying patches")
1365 for patch in build.patch:
1366 patch = patch.strip()
1367 logging.info("Applying " + patch)
1368 patch_path = os.path.join('metadata', app.id, patch)
1369 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1370 if p.returncode != 0:
1371 raise BuildException("Failed to apply patch %s" % patch_path)
1373 # Get required source libraries
1376 logging.info("Collecting source libraries")
1377 for lib in build.srclibs:
1378 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1379 refresh=refresh, build=build))
1381 for name, number, libpath in srclibpaths:
1382 place_srclib(root_dir, int(number) if number else None, libpath)
1384 basesrclib = vcs.getsrclib()
1385 # If one was used for the main source, add that too.
1387 srclibpaths.append(basesrclib)
1389 # Update the local.properties file
1390 localprops = [os.path.join(build_dir, 'local.properties')]
1392 parts = build.subdir.split(os.sep)
1395 cur = os.path.join(cur, d)
1396 localprops += [os.path.join(cur, 'local.properties')]
1397 for path in localprops:
1399 if os.path.isfile(path):
1400 logging.info("Updating local.properties file at %s" % path)
1401 with open(path, 'r', encoding='iso-8859-1') as f:
1405 logging.info("Creating local.properties file at %s" % path)
1406 # Fix old-fashioned 'sdk-location' by copying
1407 # from sdk.dir, if necessary
1409 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1410 re.S | re.M).group(1)
1411 props += "sdk-location=%s\n" % sdkloc
1413 props += "sdk.dir=%s\n" % config['sdk_path']
1414 props += "sdk-location=%s\n" % config['sdk_path']
1415 ndk_path = build.ndk_path()
1416 # if for any reason the path isn't valid or the directory
1417 # doesn't exist, some versions of Gradle will error with a
1418 # cryptic message (even if the NDK is not even necessary).
1419 # https://gitlab.com/fdroid/fdroidserver/issues/171
1420 if ndk_path and os.path.exists(ndk_path):
1422 props += "ndk.dir=%s\n" % ndk_path
1423 props += "ndk-location=%s\n" % ndk_path
1424 # Add java.encoding if necessary
1426 props += "java.encoding=%s\n" % build.encoding
1427 with open(path, 'w', encoding='iso-8859-1') as f:
1431 if build.build_method() == 'gradle':
1432 flavours = build.gradle
1435 n = build.target.split('-')[1]
1436 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1437 r'compileSdkVersion %s' % n,
1438 os.path.join(root_dir, 'build.gradle'))
1440 # Remove forced debuggable flags
1441 remove_debuggable_flags(root_dir)
1443 # Insert version code and number into the manifest if necessary
1444 if build.forceversion:
1445 logging.info("Changing the version name")
1446 for path in manifest_paths(root_dir, flavours):
1447 if not os.path.isfile(path):
1449 if has_extension(path, 'xml'):
1450 regsub_file(r'android:versionName="[^"]*"',
1451 r'android:versionName="%s"' % build.versionName,
1453 elif has_extension(path, 'gradle'):
1454 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1455 r"""\1versionName '%s'""" % build.versionName,
1458 if build.forcevercode:
1459 logging.info("Changing the version code")
1460 for path in manifest_paths(root_dir, flavours):
1461 if not os.path.isfile(path):
1463 if has_extension(path, 'xml'):
1464 regsub_file(r'android:versionCode="[^"]*"',
1465 r'android:versionCode="%s"' % build.versionCode,
1467 elif has_extension(path, 'gradle'):
1468 regsub_file(r'versionCode[ =]+[0-9]+',
1469 r'versionCode %s' % build.versionCode,
1472 # Delete unwanted files
1474 logging.info("Removing specified files")
1475 for part in getpaths(build_dir, build.rm):
1476 dest = os.path.join(build_dir, part)
1477 logging.info("Removing {0}".format(part))
1478 if os.path.lexists(dest):
1479 if os.path.islink(dest):
1480 FDroidPopen(['unlink', dest], output=False)
1482 FDroidPopen(['rm', '-rf', dest], output=False)
1484 logging.info("...but it didn't exist")
1486 remove_signing_keys(build_dir)
1488 # Add required external libraries
1490 logging.info("Collecting prebuilt libraries")
1491 libsdir = os.path.join(root_dir, 'libs')
1492 if not os.path.exists(libsdir):
1494 for lib in build.extlibs:
1496 logging.info("...installing extlib {0}".format(lib))
1497 libf = os.path.basename(lib)
1498 libsrc = os.path.join(extlib_dir, lib)
1499 if not os.path.exists(libsrc):
1500 raise BuildException("Missing extlib file {0}".format(libsrc))
1501 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1503 # Run a pre-build command if one is required
1505 logging.info("Running 'prebuild' commands in %s" % root_dir)
1507 cmd = replace_config_vars(build.prebuild, build)
1509 # Substitute source library paths into prebuild commands
1510 for name, number, libpath in srclibpaths:
1511 libpath = os.path.relpath(libpath, root_dir)
1512 cmd = cmd.replace('$$' + name + '$$', libpath)
1514 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1515 if p.returncode != 0:
1516 raise BuildException("Error running prebuild command for %s:%s" %
1517 (app.id, build.versionName), p.output)
1519 # Generate (or update) the ant build file, build.xml...
1520 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1521 parms = ['android', 'update', 'lib-project']
1522 lparms = ['android', 'update', 'project']
1525 parms += ['-t', build.target]
1526 lparms += ['-t', build.target]
1527 if build.androidupdate:
1528 update_dirs = build.androidupdate
1530 update_dirs = ant_subprojects(root_dir) + ['.']
1532 for d in update_dirs:
1533 subdir = os.path.join(root_dir, d)
1535 logging.debug("Updating main project")
1536 cmd = parms + ['-p', d]
1538 logging.debug("Updating subproject %s" % d)
1539 cmd = lparms + ['-p', d]
1540 p = SdkToolsPopen(cmd, cwd=root_dir)
1541 # Check to see whether an error was returned without a proper exit
1542 # code (this is the case for the 'no target set or target invalid'
1544 if p.returncode != 0 or p.output.startswith("Error: "):
1545 raise BuildException("Failed to update project at %s" % d, p.output)
1546 # Clean update dirs via ant
1548 logging.info("Cleaning subproject %s" % d)
1549 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1551 return (root_dir, srclibpaths)
1554 # Extend via globbing the paths from a field and return them as a map from
1555 # original path to resulting paths
1556 def getpaths_map(build_dir, globpaths):
1560 full_path = os.path.join(build_dir, p)
1561 full_path = os.path.normpath(full_path)
1562 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1564 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1568 # Extend via globbing the paths from a field and return them as a set
1569 def getpaths(build_dir, globpaths):
1570 paths_map = getpaths_map(build_dir, globpaths)
1572 for k, v in paths_map.items():
1579 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1585 self.path = os.path.join('stats', 'known_apks.txt')
1587 if os.path.isfile(self.path):
1588 with open(self.path, 'r', encoding='utf8') as f:
1590 t = line.rstrip().split(' ')
1592 self.apks[t[0]] = (t[1], None)
1594 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1595 self.changed = False
1597 def writeifchanged(self):
1598 if not self.changed:
1601 if not os.path.exists('stats'):
1605 for apk, app in self.apks.items():
1607 line = apk + ' ' + appid
1609 line += ' ' + added.strftime('%Y-%m-%d')
1612 with open(self.path, 'w', encoding='utf8') as f:
1613 for line in sorted(lst, key=natural_key):
1614 f.write(line + '\n')
1616 def recordapk(self, apk, app, default_date=None):
1618 Record an apk (if it's new, otherwise does nothing)
1619 Returns the date it was added as a datetime instance
1621 if apk not in self.apks:
1622 if default_date is None:
1623 default_date = datetime.utcnow()
1624 self.apks[apk] = (app, default_date)
1626 _, added = self.apks[apk]
1629 # Look up information - given the 'apkname', returns (app id, date added/None).
1630 # Or returns None for an unknown apk.
1631 def getapp(self, apkname):
1632 if apkname in self.apks:
1633 return self.apks[apkname]
1636 # Get the most recent 'num' apps added to the repo, as a list of package ids
1637 # with the most recent first.
1638 def getlatest(self, num):
1640 for apk, app in self.apks.items():
1644 if apps[appid] > added:
1648 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1649 lst = [app for app, _ in sortedapps]
1654 def get_file_extension(filename):
1655 """get the normalized file extension, can be blank string but never None"""
1656 if isinstance(filename, bytes):
1657 filename = filename.decode('utf-8')
1658 return os.path.splitext(filename)[1].lower()[1:]
1661 def get_apk_debuggable_aapt(apkfile):
1662 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1664 if p.returncode != 0:
1665 raise FDroidException("Failed to get apk manifest information")
1666 for line in p.output.splitlines():
1667 if 'android:debuggable' in line and not line.endswith('0x0'):
1672 def get_apk_debuggable_androguard(apkfile):
1674 from androguard.core.bytecodes.apk import APK
1676 raise FDroidException("androguard library is not installed and aapt not present")
1678 apkobject = APK(apkfile)
1679 if apkobject.is_valid_APK():
1680 debuggable = apkobject.get_element("application", "debuggable")
1681 if debuggable is not None:
1682 return bool(strtobool(debuggable))
1686 def isApkAndDebuggable(apkfile):
1687 """Returns True if the given file is an APK and is debuggable
1689 :param apkfile: full path to the apk to check"""
1691 if get_file_extension(apkfile) != 'apk':
1694 if SdkToolsPopen(['aapt', 'version'], output=False):
1695 return get_apk_debuggable_aapt(apkfile)
1697 return get_apk_debuggable_androguard(apkfile)
1702 self.returncode = None
1706 def SdkToolsPopen(commands, cwd=None, output=True):
1708 if cmd not in config:
1709 config[cmd] = find_sdk_tools_cmd(commands[0])
1710 abscmd = config[cmd]
1712 raise FDroidException("Could not find '%s' on your system" % cmd)
1714 test_aapt_version(config['aapt'])
1715 return FDroidPopen([abscmd] + commands[1:],
1716 cwd=cwd, output=output)
1719 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1721 Run a command and capture the possibly huge output as bytes.
1723 :param commands: command and argument list like in subprocess.Popen
1724 :param cwd: optionally specifies a working directory
1725 :param envs: a optional dictionary of environment variables and their values
1726 :returns: A PopenResult.
1731 set_FDroidPopen_env()
1733 process_env = env.copy()
1734 if envs is not None and len(envs) > 0:
1735 process_env.update(envs)
1738 cwd = os.path.normpath(cwd)
1739 logging.debug("Directory: %s" % cwd)
1740 logging.debug("> %s" % ' '.join(commands))
1742 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1743 result = PopenResult()
1746 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1747 stdout=subprocess.PIPE, stderr=stderr_param)
1748 except OSError as e:
1749 raise BuildException("OSError while trying to execute " +
1750 ' '.join(commands) + ': ' + str(e))
1752 if not stderr_to_stdout and options.verbose:
1753 stderr_queue = Queue()
1754 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1756 while not stderr_reader.eof():
1757 while not stderr_queue.empty():
1758 line = stderr_queue.get()
1759 sys.stderr.buffer.write(line)
1764 stdout_queue = Queue()
1765 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1768 # Check the queue for output (until there is no more to get)
1769 while not stdout_reader.eof():
1770 while not stdout_queue.empty():
1771 line = stdout_queue.get()
1772 if output and options.verbose:
1773 # Output directly to console
1774 sys.stderr.buffer.write(line)
1780 result.returncode = p.wait()
1781 result.output = buf.getvalue()
1786 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1788 Run a command and capture the possibly huge output as a str.
1790 :param commands: command and argument list like in subprocess.Popen
1791 :param cwd: optionally specifies a working directory
1792 :param envs: a optional dictionary of environment variables and their values
1793 :returns: A PopenResult.
1795 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1796 result.output = result.output.decode('utf-8', 'ignore')
1800 gradle_comment = re.compile(r'[ ]*//')
1801 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1802 gradle_line_matches = [
1803 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1804 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1805 re.compile(r'.*\.readLine\(.*'),
1809 def remove_signing_keys(build_dir):
1810 for root, dirs, files in os.walk(build_dir):
1811 if 'build.gradle' in files:
1812 path = os.path.join(root, 'build.gradle')
1814 with open(path, "r", encoding='utf8') as o:
1815 lines = o.readlines()
1821 with open(path, "w", encoding='utf8') as o:
1822 while i < len(lines):
1825 while line.endswith('\\\n'):
1826 line = line.rstrip('\\\n') + lines[i]
1829 if gradle_comment.match(line):
1834 opened += line.count('{')
1835 opened -= line.count('}')
1838 if gradle_signing_configs.match(line):
1843 if any(s.match(line) for s in gradle_line_matches):
1851 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1854 'project.properties',
1856 'default.properties',
1857 'ant.properties', ]:
1858 if propfile in files:
1859 path = os.path.join(root, propfile)
1861 with open(path, "r", encoding='iso-8859-1') as o:
1862 lines = o.readlines()
1866 with open(path, "w", encoding='iso-8859-1') as o:
1868 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1875 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1878 def set_FDroidPopen_env(build=None):
1880 set up the environment variables for the build environment
1882 There is only a weak standard, the variables used by gradle, so also set
1883 up the most commonly used environment variables for SDK and NDK. Also, if
1884 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1886 global env, orig_path
1890 orig_path = env['PATH']
1891 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1892 env[n] = config['sdk_path']
1893 for k, v in config['java_paths'].items():
1894 env['JAVA%s_HOME' % k] = v
1896 missinglocale = True
1897 for k, v in env.items():
1898 if k == 'LANG' and v != 'C':
1899 missinglocale = False
1901 missinglocale = False
1903 env['LANG'] = 'en_US.UTF-8'
1905 if build is not None:
1906 path = build.ndk_path()
1907 paths = orig_path.split(os.pathsep)
1908 if path not in paths:
1909 paths = [path] + paths
1910 env['PATH'] = os.pathsep.join(paths)
1911 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1912 env[n] = build.ndk_path()
1915 def replace_build_vars(cmd, build):
1916 cmd = cmd.replace('$$COMMIT$$', build.commit)
1917 cmd = cmd.replace('$$VERSION$$', build.versionName)
1918 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1922 def replace_config_vars(cmd, build):
1923 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1924 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1925 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1926 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1927 if build is not None:
1928 cmd = replace_build_vars(cmd, build)
1932 def place_srclib(root_dir, number, libpath):
1935 relpath = os.path.relpath(libpath, root_dir)
1936 proppath = os.path.join(root_dir, 'project.properties')
1939 if os.path.isfile(proppath):
1940 with open(proppath, "r", encoding='iso-8859-1') as o:
1941 lines = o.readlines()
1943 with open(proppath, "w", encoding='iso-8859-1') as o:
1946 if line.startswith('android.library.reference.%d=' % number):
1947 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1952 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1955 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1958 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1959 """Verify that two apks are the same
1961 One of the inputs is signed, the other is unsigned. The signature metadata
1962 is transferred from the signed to the unsigned apk, and then jarsigner is
1963 used to verify that the signature from the signed apk is also varlid for
1964 the unsigned one. If the APK given as unsigned actually does have a
1965 signature, it will be stripped out and ignored.
1967 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1968 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1969 into AndroidManifest.xml, but that makes the build not reproducible. So
1970 instead they are included as separate files in the APK's META-INF/ folder.
1971 If those files exist in the signed APK, they will be part of the signature
1972 and need to also be included in the unsigned APK for it to validate.
1974 :param signed_apk: Path to a signed apk file
1975 :param unsigned_apk: Path to an unsigned apk file expected to match it
1976 :param tmp_dir: Path to directory for temporary files
1977 :returns: None if the verification is successful, otherwise a string
1978 describing what went wrong.
1981 signed = ZipFile(signed_apk, 'r')
1982 meta_inf_files = ['META-INF/MANIFEST.MF']
1983 for f in signed.namelist():
1984 if apk_sigfile.match(f) \
1985 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
1986 meta_inf_files.append(f)
1987 if len(meta_inf_files) < 3:
1988 return "Signature files missing from {0}".format(signed_apk)
1990 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
1991 unsigned = ZipFile(unsigned_apk, 'r')
1992 # only read the signature from the signed APK, everything else from unsigned
1993 with ZipFile(tmp_apk, 'w') as tmp:
1994 for filename in meta_inf_files:
1995 tmp.writestr(signed.getinfo(filename), signed.read(filename))
1996 for info in unsigned.infolist():
1997 if info.filename in meta_inf_files:
1998 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2000 if info.filename in tmp.namelist():
2001 return "duplicate filename found: " + info.filename
2002 tmp.writestr(info, unsigned.read(info.filename))
2006 verified = verify_apk_signature(tmp_apk)
2009 logging.info("...NOT verified - {0}".format(tmp_apk))
2010 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2011 os.path.dirname(unsigned_apk))
2013 logging.info("...successfully verified")
2017 def verify_apk_signature(apk, jar=False):
2018 """verify the signature on an APK
2020 Try to use apksigner whenever possible since jarsigner is very
2021 shitty: unsigned APKs pass as "verified"! So this has to turn on
2022 -strict then check for result 4.
2024 You can set :param: jar to True if you want to use this method
2025 to verify jar signatures.
2027 if set_command_in_config('apksigner'):
2028 args = [config['apksigner'], 'verify']
2030 args += ['--min-sdk-version=1']
2031 return subprocess.call(args + [apk]) == 0
2033 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2034 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2037 apk_badchars = re.compile('''[/ :;'"]''')
2040 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2043 Returns None if the apk content is the same (apart from the signing key),
2044 otherwise a string describing what's different, or what went wrong when
2045 trying to do the comparison.
2051 absapk1 = os.path.abspath(apk1)
2052 absapk2 = os.path.abspath(apk2)
2054 if set_command_in_config('diffoscope'):
2055 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2056 htmlfile = logfilename + '.diffoscope.html'
2057 textfile = logfilename + '.diffoscope.txt'
2058 if subprocess.call([config['diffoscope'],
2059 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2060 '--html', htmlfile, '--text', textfile,
2061 absapk1, absapk2]) != 0:
2062 return("Failed to unpack " + apk1)
2064 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2065 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2066 for d in [apk1dir, apk2dir]:
2067 if os.path.exists(d):
2070 os.mkdir(os.path.join(d, 'jar-xf'))
2072 if subprocess.call(['jar', 'xf',
2073 os.path.abspath(apk1)],
2074 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2075 return("Failed to unpack " + apk1)
2076 if subprocess.call(['jar', 'xf',
2077 os.path.abspath(apk2)],
2078 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2079 return("Failed to unpack " + apk2)
2081 if set_command_in_config('apktool'):
2082 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2084 return("Failed to unpack " + apk1)
2085 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2087 return("Failed to unpack " + apk2)
2089 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2090 lines = p.output.splitlines()
2091 if len(lines) != 1 or 'META-INF' not in lines[0]:
2092 if set_command_in_config('meld'):
2093 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2094 return("Unexpected diff output - " + p.output)
2096 # since everything verifies, delete the comparison to keep cruft down
2097 shutil.rmtree(apk1dir)
2098 shutil.rmtree(apk2dir)
2100 # If we get here, it seems like they're the same!
2104 def set_command_in_config(command):
2105 '''Try to find specified command in the path, if it hasn't been
2106 manually set in config.py. If found, it is added to the config
2107 dict. The return value says whether the command is available.
2110 if command in config:
2113 tmp = find_command(command)
2115 config[command] = tmp
2120 def find_command(command):
2121 '''find the full path of a command, or None if it can't be found in the PATH'''
2124 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2126 fpath, fname = os.path.split(command)
2131 for path in os.environ["PATH"].split(os.pathsep):
2132 path = path.strip('"')
2133 exe_file = os.path.join(path, command)
2134 if is_exe(exe_file):
2141 '''generate a random password for when generating keys'''
2142 h = hashlib.sha256()
2143 h.update(os.urandom(16)) # salt
2144 h.update(socket.getfqdn().encode('utf-8'))
2145 passwd = base64.b64encode(h.digest()).strip()
2146 return passwd.decode('utf-8')
2149 def genkeystore(localconfig):
2151 Generate a new key with password provided in :param localconfig and add it to new keystore
2152 :return: hexed public key, public key fingerprint
2154 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2155 keystoredir = os.path.dirname(localconfig['keystore'])
2156 if keystoredir is None or keystoredir == '':
2157 keystoredir = os.path.join(os.getcwd(), keystoredir)
2158 if not os.path.exists(keystoredir):
2159 os.makedirs(keystoredir, mode=0o700)
2162 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2163 'FDROID_KEY_PASS': localconfig['keypass'],
2165 p = FDroidPopen([config['keytool'], '-genkey',
2166 '-keystore', localconfig['keystore'],
2167 '-alias', localconfig['repo_keyalias'],
2168 '-keyalg', 'RSA', '-keysize', '4096',
2169 '-sigalg', 'SHA256withRSA',
2170 '-validity', '10000',
2171 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2172 '-keypass:env', 'FDROID_KEY_PASS',
2173 '-dname', localconfig['keydname']], envs=env_vars)
2174 if p.returncode != 0:
2175 raise BuildException("Failed to generate key", p.output)
2176 os.chmod(localconfig['keystore'], 0o0600)
2177 if not options.quiet:
2178 # now show the lovely key that was just generated
2179 p = FDroidPopen([config['keytool'], '-list', '-v',
2180 '-keystore', localconfig['keystore'],
2181 '-alias', localconfig['repo_keyalias'],
2182 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2183 logging.info(p.output.strip() + '\n\n')
2184 # get the public key
2185 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2186 '-keystore', localconfig['keystore'],
2187 '-alias', localconfig['repo_keyalias'],
2188 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2189 + config['smartcardoptions'],
2190 envs=env_vars, output=False, stderr_to_stdout=False)
2191 if p.returncode != 0 or len(p.output) < 20:
2192 raise BuildException("Failed to get public key", p.output)
2194 fingerprint = get_cert_fingerprint(pubkey)
2195 return hexlify(pubkey), fingerprint
2198 def get_cert_fingerprint(pubkey):
2200 Generate a certificate fingerprint the same way keytool does it
2201 (but with slightly different formatting)
2203 digest = hashlib.sha256(pubkey).digest()
2204 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2205 return " ".join(ret)
2208 def get_certificate(certificate_file):
2210 Extracts a certificate from the given file.
2211 :param certificate_file: file bytes (as string) representing the certificate
2212 :return: A binary representation of the certificate's public key, or None in case of error
2214 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2215 if content.getComponentByName('contentType') != rfc2315.signedData:
2217 content = decoder.decode(content.getComponentByName('content'),
2218 asn1Spec=rfc2315.SignedData())[0]
2220 certificates = content.getComponentByName('certificates')
2221 cert = certificates[0].getComponentByName('certificate')
2223 logging.error("Certificates not found.")
2225 return encoder.encode(cert)
2228 def write_to_config(thisconfig, key, value=None, config_file=None):
2229 '''write a key/value to the local config.py
2231 NOTE: only supports writing string variables.
2233 :param thisconfig: config dictionary
2234 :param key: variable name in config.py to be overwritten/added
2235 :param value: optional value to be written, instead of fetched
2236 from 'thisconfig' dictionary.
2239 origkey = key + '_orig'
2240 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2241 cfg = config_file if config_file else 'config.py'
2244 with open(cfg, 'r', encoding="utf-8") as f:
2245 lines = f.readlines()
2247 # make sure the file ends with a carraige return
2249 if not lines[-1].endswith('\n'):
2252 # regex for finding and replacing python string variable
2253 # definitions/initializations
2254 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2255 repl = key + ' = "' + value + '"'
2256 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2257 repl2 = key + " = '" + value + "'"
2259 # If we replaced this line once, we make sure won't be a
2260 # second instance of this line for this key in the document.
2263 with open(cfg, 'w', encoding="utf-8") as f:
2265 if pattern.match(line) or pattern2.match(line):
2267 line = pattern.sub(repl, line)
2268 line = pattern2.sub(repl2, line)
2279 def parse_xml(path):
2280 return XMLElementTree.parse(path).getroot()
2283 def string_is_integer(string):
2291 def get_per_app_repos():
2292 '''per-app repos are dirs named with the packageName of a single app'''
2294 # Android packageNames are Java packages, they may contain uppercase or
2295 # lowercase letters ('A' through 'Z'), numbers, and underscores
2296 # ('_'). However, individual package name parts may only start with
2297 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2298 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2301 for root, dirs, files in os.walk(os.getcwd()):
2303 print('checking', root, 'for', d)
2304 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2305 # standard parts of an fdroid repo, so never packageNames
2308 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2314 def is_repo_file(filename):
2315 '''Whether the file in a repo is a build product to be delivered to users'''
2316 if isinstance(filename, str):
2317 filename = filename.encode('utf-8', errors="surrogateescape")
2318 return os.path.isfile(filename) \
2319 and not filename.endswith(b'.asc') \
2320 and not filename.endswith(b'.sig') \
2321 and os.path.basename(filename) not in [
2323 b'index_unsigned.jar',