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)$')
58 APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
59 STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
61 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
70 'sdk_path': "$ANDROID_HOME",
75 'r12b': "$ANDROID_NDK",
80 'build_tools': "25.0.2",
81 'force_build_tools': False,
86 'accepted_formats': ['txt', 'yml'],
87 'sync_from_local_copy_dir': False,
88 'per_app_repos': False,
89 'make_current_version_link': True,
90 'current_version_name_source': 'Name',
91 'update_stats': False,
95 'stats_to_carbon': False,
97 'build_server_always': False,
98 'keystore': 'keystore.jks',
99 'smartcardoptions': [],
109 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
110 'repo_name': "My First FDroid Repo Demo",
111 'repo_icon': "fdroid-icon.png",
112 'repo_description': '''
113 This is a repository of apps to be used with FDroid. Applications in this
114 repository are either official binaries built by the original application
115 developers, or are binaries built from source by the admin of f-droid.org
116 using the tools on https://gitlab.com/u/fdroid.
122 def setup_global_opts(parser):
123 parser.add_argument("-v", "--verbose", action="store_true", default=False,
124 help="Spew out even more information than normal")
125 parser.add_argument("-q", "--quiet", action="store_true", default=False,
126 help="Restrict output to warnings and errors")
129 def fill_config_defaults(thisconfig):
130 for k, v in default_config.items():
131 if k not in thisconfig:
134 # Expand paths (~users and $vars)
135 def expand_path(path):
139 path = os.path.expanduser(path)
140 path = os.path.expandvars(path)
145 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
150 thisconfig[k + '_orig'] = v
152 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
153 if thisconfig['java_paths'] is None:
154 thisconfig['java_paths'] = dict()
156 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
157 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
158 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
159 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
160 if os.getenv('JAVA_HOME') is not None:
161 pathlist.append(os.getenv('JAVA_HOME'))
162 if os.getenv('PROGRAMFILES') is not None:
163 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
164 for d in sorted(pathlist):
165 if os.path.islink(d):
167 j = os.path.basename(d)
168 # the last one found will be the canonical one, so order appropriately
170 r'^1\.([6-9])\.0\.jdk$', # OSX
171 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
172 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
173 r'^jdk([6-9])-openjdk$', # Arch
174 r'^java-([6-9])-openjdk$', # Arch
175 r'^java-([6-9])-jdk$', # Arch (oracle)
176 r'^java-1\.([6-9])\.0-.*$', # RedHat
177 r'^java-([6-9])-oracle$', # Debian WebUpd8
178 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
179 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
181 m = re.match(regex, j)
184 for p in [d, os.path.join(d, 'Contents', 'Home')]:
185 if os.path.exists(os.path.join(p, 'bin', 'javac')):
186 thisconfig['java_paths'][m.group(1)] = p
188 for java_version in ('7', '8', '9'):
189 if java_version not in thisconfig['java_paths']:
191 java_home = thisconfig['java_paths'][java_version]
192 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
193 if os.path.exists(jarsigner):
194 thisconfig['jarsigner'] = jarsigner
195 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
196 break # Java7 is preferred, so quit if found
198 for k in ['ndk_paths', 'java_paths']:
204 thisconfig[k][k2] = exp
205 thisconfig[k][k2 + '_orig'] = v
208 def regsub_file(pattern, repl, path):
209 with open(path, 'rb') as f:
211 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
212 with open(path, 'wb') as f:
216 def read_config(opts, config_file='config.py'):
217 """Read the repository config
219 The config is read from config_file, which is in the current
220 directory when any of the repo management commands are used. If
221 there is a local metadata file in the git repo, then config.py is
222 not required, just use defaults.
225 global config, options
227 if config is not None:
234 if os.path.isfile(config_file):
235 logging.debug("Reading %s" % config_file)
236 with io.open(config_file, "rb") as f:
237 code = compile(f.read(), config_file, 'exec')
238 exec(code, None, config)
239 elif len(get_local_metadata_files()) == 0:
240 raise FDroidException("Missing config file - is this a repo directory?")
242 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
244 if not type(config[k]) in (str, list, tuple):
245 logging.warn('"' + k + '" will be in random order!'
246 + ' Use () or [] brackets if order is important!')
248 # smartcardoptions must be a list since its command line args for Popen
249 if 'smartcardoptions' in config:
250 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
251 elif 'keystore' in config and config['keystore'] == 'NONE':
252 # keystore='NONE' means use smartcard, these are required defaults
253 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
254 'SunPKCS11-OpenSC', '-providerClass',
255 'sun.security.pkcs11.SunPKCS11',
256 '-providerArg', 'opensc-fdroid.cfg']
258 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
259 st = os.stat(config_file)
260 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
261 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
263 fill_config_defaults(config)
265 for k in ["repo_description", "archive_description"]:
267 config[k] = clean_description(config[k])
269 if 'serverwebroot' in config:
270 if isinstance(config['serverwebroot'], str):
271 roots = [config['serverwebroot']]
272 elif all(isinstance(item, str) for item in config['serverwebroot']):
273 roots = config['serverwebroot']
275 raise TypeError('only accepts strings, lists, and tuples')
277 for rootstr in roots:
278 # since this is used with rsync, where trailing slashes have
279 # meaning, ensure there is always a trailing slash
280 if rootstr[-1] != '/':
282 rootlist.append(rootstr.replace('//', '/'))
283 config['serverwebroot'] = rootlist
285 if 'servergitmirrors' in config:
286 if isinstance(config['servergitmirrors'], str):
287 roots = [config['servergitmirrors']]
288 elif all(isinstance(item, str) for item in config['servergitmirrors']):
289 roots = config['servergitmirrors']
291 raise TypeError('only accepts strings, lists, and tuples')
292 config['servergitmirrors'] = roots
297 def find_sdk_tools_cmd(cmd):
298 '''find a working path to a tool from the Android SDK'''
301 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
302 # try to find a working path to this command, in all the recent possible paths
303 if 'build_tools' in config:
304 build_tools = os.path.join(config['sdk_path'], 'build-tools')
305 # if 'build_tools' was manually set and exists, check only that one
306 configed_build_tools = os.path.join(build_tools, config['build_tools'])
307 if os.path.exists(configed_build_tools):
308 tooldirs.append(configed_build_tools)
310 # no configed version, so hunt known paths for it
311 for f in sorted(os.listdir(build_tools), reverse=True):
312 if os.path.isdir(os.path.join(build_tools, f)):
313 tooldirs.append(os.path.join(build_tools, f))
314 tooldirs.append(build_tools)
315 sdk_tools = os.path.join(config['sdk_path'], 'tools')
316 if os.path.exists(sdk_tools):
317 tooldirs.append(sdk_tools)
318 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
319 if os.path.exists(sdk_platform_tools):
320 tooldirs.append(sdk_platform_tools)
321 tooldirs.append('/usr/bin')
323 path = os.path.join(d, cmd)
324 if os.path.isfile(path):
326 test_aapt_version(path)
328 # did not find the command, exit with error message
329 ensure_build_tools_exists(config)
332 def test_aapt_version(aapt):
333 '''Check whether the version of aapt is new enough'''
334 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
335 if output is None or output == '':
336 logging.error(aapt + ' failed to execute!')
338 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
343 # the Debian package has the version string like "v0.2-23.0.2"
344 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
345 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
347 logging.warning('Unknown version of aapt, might cause problems: ' + output)
350 def test_sdk_exists(thisconfig):
351 if 'sdk_path' not in thisconfig:
352 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
353 test_aapt_version(thisconfig['aapt'])
356 logging.error("'sdk_path' not set in config.py!")
358 if thisconfig['sdk_path'] == default_config['sdk_path']:
359 logging.error('No Android SDK found!')
360 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
361 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
363 if not os.path.exists(thisconfig['sdk_path']):
364 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
366 if not os.path.isdir(thisconfig['sdk_path']):
367 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
369 for d in ['build-tools', 'platform-tools', 'tools']:
370 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
371 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
372 thisconfig['sdk_path'], d))
377 def ensure_build_tools_exists(thisconfig):
378 if not test_sdk_exists(thisconfig):
379 raise FDroidException("Android SDK not found.")
380 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
381 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
382 if not os.path.isdir(versioned_build_tools):
383 raise FDroidException(
384 'Android Build Tools path "' + versioned_build_tools + '" does not exist!')
387 def get_local_metadata_files():
388 '''get any metadata files local to an app's source repo
390 This tries to ignore anything that does not count as app metdata,
391 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
394 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
397 def read_pkg_args(args, allow_vercodes=False):
399 Given the arguments in the form of multiple appid:[vc] strings, this returns
400 a dictionary with the set of vercodes specified for each package.
408 if allow_vercodes and ':' in p:
409 package, vercode = p.split(':')
411 package, vercode = p, None
412 if package not in vercodes:
413 vercodes[package] = [vercode] if vercode else []
415 elif vercode and vercode not in vercodes[package]:
416 vercodes[package] += [vercode] if vercode else []
421 def read_app_args(args, allapps, allow_vercodes=False):
423 On top of what read_pkg_args does, this returns the whole app metadata, but
424 limiting the builds list to the builds matching the vercodes specified.
427 vercodes = read_pkg_args(args, allow_vercodes)
433 for appid, app in allapps.items():
434 if appid in vercodes:
437 if len(apps) != len(vercodes):
440 logging.critical("No such package: %s" % p)
441 raise FDroidException("Found invalid app ids in arguments")
443 raise FDroidException("No packages specified")
446 for appid, app in apps.items():
450 app.builds = [b for b in app.builds if b.versionCode in vc]
451 if len(app.builds) != len(vercodes[appid]):
453 allvcs = [b.versionCode for b in app.builds]
454 for v in vercodes[appid]:
456 logging.critical("No such vercode %s for app %s" % (v, appid))
459 raise FDroidException("Found invalid vercodes for some apps")
464 def get_extension(filename):
465 base, ext = os.path.splitext(filename)
468 return base, ext.lower()[1:]
471 def has_extension(filename, ext):
472 _, f_ext = get_extension(filename)
476 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
479 def clean_description(description):
480 'Remove unneeded newlines and spaces from a block of description text'
482 # this is split up by paragraph to make removing the newlines easier
483 for paragraph in re.split(r'\n\n', description):
484 paragraph = re.sub('\r', '', paragraph)
485 paragraph = re.sub('\n', ' ', paragraph)
486 paragraph = re.sub(' {2,}', ' ', paragraph)
487 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
488 returnstring += paragraph + '\n\n'
489 return returnstring.rstrip('\n')
492 def publishednameinfo(filename):
493 filename = os.path.basename(filename)
494 m = publish_name_regex.match(filename)
496 result = (m.group(1), m.group(2))
497 except AttributeError:
498 raise FDroidException("Invalid name for published file: %s" % filename)
502 def get_release_filename(app, build):
504 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
506 return "%s_%s.apk" % (app.id, build.versionCode)
509 def get_toolsversion_logname(app, build):
510 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
513 def getsrcname(app, build):
514 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
526 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
529 def get_build_dir(app):
530 '''get the dir that this app will be built in'''
532 if app.RepoType == 'srclib':
533 return os.path.join('build', 'srclib', app.Repo)
535 return os.path.join('build', app.id)
539 '''checkout code from VCS and return instance of vcs and the build dir'''
540 build_dir = get_build_dir(app)
542 # Set up vcs interface and make sure we have the latest code...
543 logging.debug("Getting {0} vcs interface for {1}"
544 .format(app.RepoType, app.Repo))
545 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
549 vcs = getvcs(app.RepoType, remote, build_dir)
551 return vcs, build_dir
554 def getvcs(vcstype, remote, local):
556 return vcs_git(remote, local)
557 if vcstype == 'git-svn':
558 return vcs_gitsvn(remote, local)
560 return vcs_hg(remote, local)
562 return vcs_bzr(remote, local)
563 if vcstype == 'srclib':
564 if local != os.path.join('build', 'srclib', remote):
565 raise VCSException("Error: srclib paths are hard-coded!")
566 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
568 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
569 raise VCSException("Invalid vcs type " + vcstype)
572 def getsrclibvcs(name):
573 if name not in fdroidserver.metadata.srclibs:
574 raise VCSException("Missing srclib " + name)
575 return fdroidserver.metadata.srclibs[name]['Repo Type']
580 def __init__(self, remote, local):
582 # svn, git-svn and bzr may require auth
584 if self.repotype() in ('git-svn', 'bzr'):
586 if self.repotype == 'git-svn':
587 raise VCSException("Authentication is not supported for git-svn")
588 self.username, remote = remote.split('@')
589 if ':' not in self.username:
590 raise VCSException("Password required with username")
591 self.username, self.password = self.username.split(':')
595 self.clone_failed = False
596 self.refreshed = False
602 # Take the local repository to a clean version of the given revision, which
603 # is specificed in the VCS's native format. Beforehand, the repository can
604 # be dirty, or even non-existent. If the repository does already exist
605 # locally, it will be updated from the origin, but only once in the
606 # lifetime of the vcs object.
607 # None is acceptable for 'rev' if you know you are cloning a clean copy of
608 # the repo - otherwise it must specify a valid revision.
609 def gotorevision(self, rev, refresh=True):
611 if self.clone_failed:
612 raise VCSException("Downloading the repository already failed once, not trying again.")
614 # The .fdroidvcs-id file for a repo tells us what VCS type
615 # and remote that directory was created from, allowing us to drop it
616 # automatically if either of those things changes.
617 fdpath = os.path.join(self.local, '..',
618 '.fdroidvcs-' + os.path.basename(self.local))
619 fdpath = os.path.normpath(fdpath)
620 cdata = self.repotype() + ' ' + self.remote
623 if os.path.exists(self.local):
624 if os.path.exists(fdpath):
625 with open(fdpath, 'r') as f:
626 fsdata = f.read().strip()
631 logging.info("Repository details for %s changed - deleting" % (
635 logging.info("Repository details for %s missing - deleting" % (
638 shutil.rmtree(self.local)
642 self.refreshed = True
645 self.gotorevisionx(rev)
646 except FDroidException as e:
649 # If necessary, write the .fdroidvcs file.
650 if writeback and not self.clone_failed:
651 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
652 with open(fdpath, 'w+') as f:
658 # Derived classes need to implement this. It's called once basic checking
659 # has been performend.
660 def gotorevisionx(self, rev): # pylint: disable=unused-argument
661 raise VCSException("This VCS type doesn't define gotorevisionx")
663 # Initialise and update submodules
664 def initsubmodules(self):
665 raise VCSException('Submodules not supported for this vcs type')
667 # Get a list of all known tags
669 if not self._gettags:
670 raise VCSException('gettags not supported for this vcs type')
672 for tag in self._gettags():
673 if re.match('[-A-Za-z0-9_. /]+$', tag):
677 # Get a list of all the known tags, sorted from newest to oldest
678 def latesttags(self):
679 raise VCSException('latesttags not supported for this vcs type')
681 # Get current commit reference (hash, revision, etc)
683 raise VCSException('getref not supported for this vcs type')
685 # Returns the srclib (name, path) used in setting up the current
696 # If the local directory exists, but is somehow not a git repository, git
697 # will traverse up the directory tree until it finds one that is (i.e.
698 # fdroidserver) and then we'll proceed to destroy it! This is called as
701 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
702 result = p.output.rstrip()
703 if not result.endswith(self.local):
704 raise VCSException('Repository mismatch')
706 def gotorevisionx(self, rev):
707 if not os.path.exists(self.local):
709 p = FDroidPopen(['git', 'clone', self.remote, self.local])
710 if p.returncode != 0:
711 self.clone_failed = True
712 raise VCSException("Git clone failed", p.output)
716 # Discard any working tree changes
717 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
718 'git', 'reset', '--hard'], cwd=self.local, output=False)
719 if p.returncode != 0:
720 raise VCSException("Git reset failed", p.output)
721 # Remove untracked files now, in case they're tracked in the target
722 # revision (it happens!)
723 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
724 'git', 'clean', '-dffx'], cwd=self.local, output=False)
725 if p.returncode != 0:
726 raise VCSException("Git clean failed", p.output)
727 if not self.refreshed:
728 # Get latest commits and tags from remote
729 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
730 if p.returncode != 0:
731 raise VCSException("Git fetch failed", p.output)
732 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
733 if p.returncode != 0:
734 raise VCSException("Git fetch failed", p.output)
735 # Recreate origin/HEAD as git clone would do it, in case it disappeared
736 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
737 if p.returncode != 0:
738 lines = p.output.splitlines()
739 if 'Multiple remote HEAD branches' not in lines[0]:
740 raise VCSException("Git remote set-head failed", p.output)
741 branch = lines[1].split(' ')[-1]
742 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
743 if p2.returncode != 0:
744 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
745 self.refreshed = True
746 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
747 # a github repo. Most of the time this is the same as origin/master.
748 rev = rev or 'origin/HEAD'
749 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
750 if p.returncode != 0:
751 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
752 # Get rid of any uncontrolled files left behind
753 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
754 if p.returncode != 0:
755 raise VCSException("Git clean failed", p.output)
757 def initsubmodules(self):
759 submfile = os.path.join(self.local, '.gitmodules')
760 if not os.path.isfile(submfile):
761 raise VCSException("No git submodules available")
763 # fix submodules not accessible without an account and public key auth
764 with open(submfile, 'r') as f:
765 lines = f.readlines()
766 with open(submfile, 'w') as f:
768 if 'git@github.com' in line:
769 line = line.replace('git@github.com:', 'https://github.com/')
770 if 'git@gitlab.com' in line:
771 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
774 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
775 if p.returncode != 0:
776 raise VCSException("Git submodule sync failed", p.output)
777 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
778 if p.returncode != 0:
779 raise VCSException("Git submodule update failed", p.output)
783 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
784 return p.output.splitlines()
786 tag_format = re.compile(r'tag: ([^),]*)')
788 def latesttags(self):
790 p = FDroidPopen(['git', 'log', '--tags',
791 '--simplify-by-decoration', '--pretty=format:%d'],
792 cwd=self.local, output=False)
794 for line in p.output.splitlines():
795 for tag in self.tag_format.findall(line):
800 class vcs_gitsvn(vcs):
805 # If the local directory exists, but is somehow not a git repository, git
806 # will traverse up the directory tree until it finds one that is (i.e.
807 # fdroidserver) and then we'll proceed to destory it! This is called as
810 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
811 result = p.output.rstrip()
812 if not result.endswith(self.local):
813 raise VCSException('Repository mismatch')
815 def gotorevisionx(self, rev):
816 if not os.path.exists(self.local):
818 gitsvn_args = ['git', 'svn', 'clone']
819 if ';' in self.remote:
820 remote_split = self.remote.split(';')
821 for i in remote_split[1:]:
822 if i.startswith('trunk='):
823 gitsvn_args.extend(['-T', i[6:]])
824 elif i.startswith('tags='):
825 gitsvn_args.extend(['-t', i[5:]])
826 elif i.startswith('branches='):
827 gitsvn_args.extend(['-b', i[9:]])
828 gitsvn_args.extend([remote_split[0], self.local])
829 p = FDroidPopen(gitsvn_args, output=False)
830 if p.returncode != 0:
831 self.clone_failed = True
832 raise VCSException("Git svn clone failed", p.output)
834 gitsvn_args.extend([self.remote, self.local])
835 p = FDroidPopen(gitsvn_args, output=False)
836 if p.returncode != 0:
837 self.clone_failed = True
838 raise VCSException("Git svn clone failed", p.output)
842 # Discard any working tree changes
843 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
844 if p.returncode != 0:
845 raise VCSException("Git reset failed", p.output)
846 # Remove untracked files now, in case they're tracked in the target
847 # revision (it happens!)
848 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
849 if p.returncode != 0:
850 raise VCSException("Git clean failed", p.output)
851 if not self.refreshed:
852 # Get new commits, branches and tags from repo
853 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
854 if p.returncode != 0:
855 raise VCSException("Git svn fetch failed")
856 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
857 if p.returncode != 0:
858 raise VCSException("Git svn rebase failed", p.output)
859 self.refreshed = True
861 rev = rev or 'master'
863 nospaces_rev = rev.replace(' ', '%20')
864 # Try finding a svn tag
865 for treeish in ['origin/', '']:
866 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
867 if p.returncode == 0:
869 if p.returncode != 0:
870 # No tag found, normal svn rev translation
871 # Translate svn rev into git format
872 rev_split = rev.split('/')
875 for treeish in ['origin/', '']:
876 if len(rev_split) > 1:
877 treeish += rev_split[0]
878 svn_rev = rev_split[1]
881 # if no branch is specified, then assume trunk (i.e. 'master' branch):
885 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
887 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
888 git_rev = p.output.rstrip()
890 if p.returncode == 0 and git_rev:
893 if p.returncode != 0 or not git_rev:
894 # Try a plain git checkout as a last resort
895 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
896 if p.returncode != 0:
897 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
899 # Check out the git rev equivalent to the svn rev
900 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
901 if p.returncode != 0:
902 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
904 # Get rid of any uncontrolled files left behind
905 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
906 if p.returncode != 0:
907 raise VCSException("Git clean failed", p.output)
911 for treeish in ['origin/', '']:
912 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
918 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
919 if p.returncode != 0:
921 return p.output.strip()
929 def gotorevisionx(self, rev):
930 if not os.path.exists(self.local):
931 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
932 if p.returncode != 0:
933 self.clone_failed = True
934 raise VCSException("Hg clone failed", p.output)
936 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
937 if p.returncode != 0:
938 raise VCSException("Hg status failed", p.output)
939 for line in p.output.splitlines():
940 if not line.startswith('? '):
941 raise VCSException("Unexpected output from hg status -uS: " + line)
942 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
943 if not self.refreshed:
944 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
945 if p.returncode != 0:
946 raise VCSException("Hg pull failed", p.output)
947 self.refreshed = True
949 rev = rev or 'default'
952 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
953 if p.returncode != 0:
954 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
955 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
956 # Also delete untracked files, we have to enable purge extension for that:
957 if "'purge' is provided by the following extension" in p.output:
958 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
959 myfile.write("\n[extensions]\nhgext.purge=\n")
960 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
961 if p.returncode != 0:
962 raise VCSException("HG purge failed", p.output)
963 elif p.returncode != 0:
964 raise VCSException("HG purge failed", p.output)
967 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
968 return p.output.splitlines()[1:]
976 def gotorevisionx(self, rev):
977 if not os.path.exists(self.local):
978 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
979 if p.returncode != 0:
980 self.clone_failed = True
981 raise VCSException("Bzr branch failed", p.output)
983 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
984 if p.returncode != 0:
985 raise VCSException("Bzr revert failed", p.output)
986 if not self.refreshed:
987 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
988 if p.returncode != 0:
989 raise VCSException("Bzr update failed", p.output)
990 self.refreshed = True
992 revargs = list(['-r', rev] if rev else [])
993 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
994 if p.returncode != 0:
995 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
998 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
999 return [tag.split(' ')[0].strip() for tag in
1000 p.output.splitlines()]
1003 def unescape_string(string):
1006 if string[0] == '"' and string[-1] == '"':
1009 return string.replace("\\'", "'")
1012 def retrieve_string(app_dir, string, xmlfiles=None):
1014 if not string.startswith('@string/'):
1015 return unescape_string(string)
1017 if xmlfiles is None:
1020 os.path.join(app_dir, 'res'),
1021 os.path.join(app_dir, 'src', 'main', 'res'),
1023 for r, d, f in os.walk(res_dir):
1024 if os.path.basename(r) == 'values':
1025 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1027 name = string[len('@string/'):]
1029 def element_content(element):
1030 if element.text is None:
1032 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1033 return s.decode('utf-8').strip()
1035 for path in xmlfiles:
1036 if not os.path.isfile(path):
1038 xml = parse_xml(path)
1039 element = xml.find('string[@name="' + name + '"]')
1040 if element is not None:
1041 content = element_content(element)
1042 return retrieve_string(app_dir, content, xmlfiles)
1047 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1048 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1051 def manifest_paths(app_dir, flavours):
1052 '''Return list of existing files that will be used to find the highest vercode'''
1054 possible_manifests = \
1055 [os.path.join(app_dir, 'AndroidManifest.xml'),
1056 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1057 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1058 os.path.join(app_dir, 'build.gradle')]
1060 for flavour in flavours:
1061 if flavour == 'yes':
1063 possible_manifests.append(
1064 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1066 return [path for path in possible_manifests if os.path.isfile(path)]
1069 def fetch_real_name(app_dir, flavours):
1070 '''Retrieve the package name. Returns the name, or None if not found.'''
1071 for path in manifest_paths(app_dir, flavours):
1072 if not has_extension(path, 'xml') or not os.path.isfile(path):
1074 logging.debug("fetch_real_name: Checking manifest at " + path)
1075 xml = parse_xml(path)
1076 app = xml.find('application')
1079 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1081 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1082 result = retrieve_string_singleline(app_dir, label)
1084 result = result.strip()
1089 def get_library_references(root_dir):
1091 proppath = os.path.join(root_dir, 'project.properties')
1092 if not os.path.isfile(proppath):
1094 with open(proppath, 'r', encoding='iso-8859-1') as f:
1096 if not line.startswith('android.library.reference.'):
1098 path = line.split('=')[1].strip()
1099 relpath = os.path.join(root_dir, path)
1100 if not os.path.isdir(relpath):
1102 logging.debug("Found subproject at %s" % path)
1103 libraries.append(path)
1107 def ant_subprojects(root_dir):
1108 subprojects = get_library_references(root_dir)
1109 for subpath in subprojects:
1110 subrelpath = os.path.join(root_dir, subpath)
1111 for p in get_library_references(subrelpath):
1112 relp = os.path.normpath(os.path.join(subpath, p))
1113 if relp not in subprojects:
1114 subprojects.insert(0, relp)
1118 def remove_debuggable_flags(root_dir):
1119 # Remove forced debuggable flags
1120 logging.debug("Removing debuggable flags from %s" % root_dir)
1121 for root, dirs, files in os.walk(root_dir):
1122 if 'AndroidManifest.xml' in files:
1123 regsub_file(r'android:debuggable="[^"]*"',
1125 os.path.join(root, 'AndroidManifest.xml'))
1128 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1129 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1130 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1133 def app_matches_packagename(app, package):
1136 appid = app.UpdateCheckName or app.id
1137 if appid is None or appid == "Ignore":
1139 return appid == package
1142 def parse_androidmanifests(paths, app):
1144 Extract some information from the AndroidManifest.xml at the given path.
1145 Returns (version, vercode, package), any or all of which might be None.
1146 All values returned are strings.
1149 ignoreversions = app.UpdateCheckIgnore
1150 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1153 return (None, None, None)
1161 if not os.path.isfile(path):
1164 logging.debug("Parsing manifest at {0}".format(path))
1169 if has_extension(path, 'gradle'):
1170 with open(path, 'r') as f:
1172 if gradle_comment.match(line):
1174 # Grab first occurence of each to avoid running into
1175 # alternative flavours and builds.
1177 matches = psearch_g(line)
1179 s = matches.group(2)
1180 if app_matches_packagename(app, s):
1183 matches = vnsearch_g(line)
1185 version = matches.group(2)
1187 matches = vcsearch_g(line)
1189 vercode = matches.group(1)
1192 xml = parse_xml(path)
1193 if "package" in xml.attrib:
1194 s = xml.attrib["package"]
1195 if app_matches_packagename(app, s):
1197 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1198 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1199 base_dir = os.path.dirname(path)
1200 version = retrieve_string_singleline(base_dir, version)
1201 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1202 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1203 if string_is_integer(a):
1206 logging.warning("Problem with xml at {0}".format(path))
1208 # Remember package name, may be defined separately from version+vercode
1210 package = max_package
1212 logging.debug("..got package={0}, version={1}, vercode={2}"
1213 .format(package, version, vercode))
1215 # Always grab the package name and version name in case they are not
1216 # together with the highest version code
1217 if max_package is None and package is not None:
1218 max_package = package
1219 if max_version is None and version is not None:
1220 max_version = version
1222 if vercode is not None \
1223 and (max_vercode is None or vercode > max_vercode):
1224 if not ignoresearch or not ignoresearch(version):
1225 if version is not None:
1226 max_version = version
1227 if vercode is not None:
1228 max_vercode = vercode
1229 if package is not None:
1230 max_package = package
1232 max_version = "Ignore"
1234 if max_version is None:
1235 max_version = "Unknown"
1237 if max_package and not is_valid_package_name(max_package):
1238 raise FDroidException("Invalid package name {0}".format(max_package))
1240 return (max_version, max_vercode, max_package)
1243 def is_valid_package_name(name):
1244 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1247 # Get the specified source library.
1248 # Returns the path to it. Normally this is the path to be used when referencing
1249 # it, which may be a subdirectory of the actual project. If you want the base
1250 # directory of the project, pass 'basepath=True'.
1251 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1252 raw=False, prepare=True, preponly=False, refresh=True,
1261 name, ref = spec.split('@')
1263 number, name = name.split(':', 1)
1265 name, subdir = name.split('/', 1)
1267 if name not in fdroidserver.metadata.srclibs:
1268 raise VCSException('srclib ' + name + ' not found.')
1270 srclib = fdroidserver.metadata.srclibs[name]
1272 sdir = os.path.join(srclib_dir, name)
1275 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1276 vcs.srclib = (name, number, sdir)
1278 vcs.gotorevision(ref, refresh)
1285 libdir = os.path.join(sdir, subdir)
1286 elif srclib["Subdir"]:
1287 for subdir in srclib["Subdir"]:
1288 libdir_candidate = os.path.join(sdir, subdir)
1289 if os.path.exists(libdir_candidate):
1290 libdir = libdir_candidate
1296 remove_signing_keys(sdir)
1297 remove_debuggable_flags(sdir)
1301 if srclib["Prepare"]:
1302 cmd = replace_config_vars(srclib["Prepare"], build)
1304 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1305 if p.returncode != 0:
1306 raise BuildException("Error running prepare command for srclib %s"
1312 return (name, number, libdir)
1315 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1318 # Prepare the source code for a particular build
1319 # 'vcs' - the appropriate vcs object for the application
1320 # 'app' - the application details from the metadata
1321 # 'build' - the build details from the metadata
1322 # 'build_dir' - the path to the build directory, usually
1324 # 'srclib_dir' - the path to the source libraries directory, usually
1326 # 'extlib_dir' - the path to the external libraries directory, usually
1328 # Returns the (root, srclibpaths) where:
1329 # 'root' is the root directory, which may be the same as 'build_dir' or may
1330 # be a subdirectory of it.
1331 # 'srclibpaths' is information on the srclibs being used
1332 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1334 # Optionally, the actual app source can be in a subdirectory
1336 root_dir = os.path.join(build_dir, build.subdir)
1338 root_dir = build_dir
1340 # Get a working copy of the right revision
1341 logging.info("Getting source for revision " + build.commit)
1342 vcs.gotorevision(build.commit, refresh)
1344 # Initialise submodules if required
1345 if build.submodules:
1346 logging.info("Initialising submodules")
1347 vcs.initsubmodules()
1349 # Check that a subdir (if we're using one) exists. This has to happen
1350 # after the checkout, since it might not exist elsewhere
1351 if not os.path.exists(root_dir):
1352 raise BuildException('Missing subdir ' + root_dir)
1354 # Run an init command if one is required
1356 cmd = replace_config_vars(build.init, build)
1357 logging.info("Running 'init' commands in %s" % root_dir)
1359 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1360 if p.returncode != 0:
1361 raise BuildException("Error running init command for %s:%s" %
1362 (app.id, build.versionName), p.output)
1364 # Apply patches if any
1366 logging.info("Applying patches")
1367 for patch in build.patch:
1368 patch = patch.strip()
1369 logging.info("Applying " + patch)
1370 patch_path = os.path.join('metadata', app.id, patch)
1371 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1372 if p.returncode != 0:
1373 raise BuildException("Failed to apply patch %s" % patch_path)
1375 # Get required source libraries
1378 logging.info("Collecting source libraries")
1379 for lib in build.srclibs:
1380 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1381 refresh=refresh, build=build))
1383 for name, number, libpath in srclibpaths:
1384 place_srclib(root_dir, int(number) if number else None, libpath)
1386 basesrclib = vcs.getsrclib()
1387 # If one was used for the main source, add that too.
1389 srclibpaths.append(basesrclib)
1391 # Update the local.properties file
1392 localprops = [os.path.join(build_dir, 'local.properties')]
1394 parts = build.subdir.split(os.sep)
1397 cur = os.path.join(cur, d)
1398 localprops += [os.path.join(cur, 'local.properties')]
1399 for path in localprops:
1401 if os.path.isfile(path):
1402 logging.info("Updating local.properties file at %s" % path)
1403 with open(path, 'r', encoding='iso-8859-1') as f:
1407 logging.info("Creating local.properties file at %s" % path)
1408 # Fix old-fashioned 'sdk-location' by copying
1409 # from sdk.dir, if necessary
1411 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1412 re.S | re.M).group(1)
1413 props += "sdk-location=%s\n" % sdkloc
1415 props += "sdk.dir=%s\n" % config['sdk_path']
1416 props += "sdk-location=%s\n" % config['sdk_path']
1417 ndk_path = build.ndk_path()
1418 # if for any reason the path isn't valid or the directory
1419 # doesn't exist, some versions of Gradle will error with a
1420 # cryptic message (even if the NDK is not even necessary).
1421 # https://gitlab.com/fdroid/fdroidserver/issues/171
1422 if ndk_path and os.path.exists(ndk_path):
1424 props += "ndk.dir=%s\n" % ndk_path
1425 props += "ndk-location=%s\n" % ndk_path
1426 # Add java.encoding if necessary
1428 props += "java.encoding=%s\n" % build.encoding
1429 with open(path, 'w', encoding='iso-8859-1') as f:
1433 if build.build_method() == 'gradle':
1434 flavours = build.gradle
1437 n = build.target.split('-')[1]
1438 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1439 r'compileSdkVersion %s' % n,
1440 os.path.join(root_dir, 'build.gradle'))
1442 # Remove forced debuggable flags
1443 remove_debuggable_flags(root_dir)
1445 # Insert version code and number into the manifest if necessary
1446 if build.forceversion:
1447 logging.info("Changing the version name")
1448 for path in manifest_paths(root_dir, flavours):
1449 if not os.path.isfile(path):
1451 if has_extension(path, 'xml'):
1452 regsub_file(r'android:versionName="[^"]*"',
1453 r'android:versionName="%s"' % build.versionName,
1455 elif has_extension(path, 'gradle'):
1456 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1457 r"""\1versionName '%s'""" % build.versionName,
1460 if build.forcevercode:
1461 logging.info("Changing the version code")
1462 for path in manifest_paths(root_dir, flavours):
1463 if not os.path.isfile(path):
1465 if has_extension(path, 'xml'):
1466 regsub_file(r'android:versionCode="[^"]*"',
1467 r'android:versionCode="%s"' % build.versionCode,
1469 elif has_extension(path, 'gradle'):
1470 regsub_file(r'versionCode[ =]+[0-9]+',
1471 r'versionCode %s' % build.versionCode,
1474 # Delete unwanted files
1476 logging.info("Removing specified files")
1477 for part in getpaths(build_dir, build.rm):
1478 dest = os.path.join(build_dir, part)
1479 logging.info("Removing {0}".format(part))
1480 if os.path.lexists(dest):
1481 if os.path.islink(dest):
1482 FDroidPopen(['unlink', dest], output=False)
1484 FDroidPopen(['rm', '-rf', dest], output=False)
1486 logging.info("...but it didn't exist")
1488 remove_signing_keys(build_dir)
1490 # Add required external libraries
1492 logging.info("Collecting prebuilt libraries")
1493 libsdir = os.path.join(root_dir, 'libs')
1494 if not os.path.exists(libsdir):
1496 for lib in build.extlibs:
1498 logging.info("...installing extlib {0}".format(lib))
1499 libf = os.path.basename(lib)
1500 libsrc = os.path.join(extlib_dir, lib)
1501 if not os.path.exists(libsrc):
1502 raise BuildException("Missing extlib file {0}".format(libsrc))
1503 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1505 # Run a pre-build command if one is required
1507 logging.info("Running 'prebuild' commands in %s" % root_dir)
1509 cmd = replace_config_vars(build.prebuild, build)
1511 # Substitute source library paths into prebuild commands
1512 for name, number, libpath in srclibpaths:
1513 libpath = os.path.relpath(libpath, root_dir)
1514 cmd = cmd.replace('$$' + name + '$$', libpath)
1516 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1517 if p.returncode != 0:
1518 raise BuildException("Error running prebuild command for %s:%s" %
1519 (app.id, build.versionName), p.output)
1521 # Generate (or update) the ant build file, build.xml...
1522 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1523 parms = ['android', 'update', 'lib-project']
1524 lparms = ['android', 'update', 'project']
1527 parms += ['-t', build.target]
1528 lparms += ['-t', build.target]
1529 if build.androidupdate:
1530 update_dirs = build.androidupdate
1532 update_dirs = ant_subprojects(root_dir) + ['.']
1534 for d in update_dirs:
1535 subdir = os.path.join(root_dir, d)
1537 logging.debug("Updating main project")
1538 cmd = parms + ['-p', d]
1540 logging.debug("Updating subproject %s" % d)
1541 cmd = lparms + ['-p', d]
1542 p = SdkToolsPopen(cmd, cwd=root_dir)
1543 # Check to see whether an error was returned without a proper exit
1544 # code (this is the case for the 'no target set or target invalid'
1546 if p.returncode != 0 or p.output.startswith("Error: "):
1547 raise BuildException("Failed to update project at %s" % d, p.output)
1548 # Clean update dirs via ant
1550 logging.info("Cleaning subproject %s" % d)
1551 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1553 return (root_dir, srclibpaths)
1556 # Extend via globbing the paths from a field and return them as a map from
1557 # original path to resulting paths
1558 def getpaths_map(build_dir, globpaths):
1562 full_path = os.path.join(build_dir, p)
1563 full_path = os.path.normpath(full_path)
1564 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1566 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1570 # Extend via globbing the paths from a field and return them as a set
1571 def getpaths(build_dir, globpaths):
1572 paths_map = getpaths_map(build_dir, globpaths)
1574 for k, v in paths_map.items():
1581 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1585 """permanent store of existing APKs with the date they were added
1587 This is currently the only way to permanently store the "updated"
1592 self.path = os.path.join('stats', 'known_apks.txt')
1594 if os.path.isfile(self.path):
1595 with open(self.path, 'r', encoding='utf8') as f:
1597 t = line.rstrip().split(' ')
1599 self.apks[t[0]] = (t[1], None)
1601 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1602 self.changed = False
1604 def writeifchanged(self):
1605 if not self.changed:
1608 if not os.path.exists('stats'):
1612 for apk, app in self.apks.items():
1614 line = apk + ' ' + appid
1616 line += ' ' + added.strftime('%Y-%m-%d')
1619 with open(self.path, 'w', encoding='utf8') as f:
1620 for line in sorted(lst, key=natural_key):
1621 f.write(line + '\n')
1623 def recordapk(self, apkName, app, default_date=None):
1625 Record an apk (if it's new, otherwise does nothing)
1626 Returns the date it was added as a datetime instance
1628 if apkName not in self.apks:
1629 if default_date is None:
1630 default_date = datetime.utcnow()
1631 self.apks[apkName] = (app, default_date)
1633 _, added = self.apks[apkName]
1636 # Look up information - given the 'apkname', returns (app id, date added/None).
1637 # Or returns None for an unknown apk.
1638 def getapp(self, apkname):
1639 if apkname in self.apks:
1640 return self.apks[apkname]
1643 # Get the most recent 'num' apps added to the repo, as a list of package ids
1644 # with the most recent first.
1645 def getlatest(self, num):
1647 for apk, app in self.apks.items():
1651 if apps[appid] > added:
1655 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1656 lst = [app for app, _ in sortedapps]
1661 def get_file_extension(filename):
1662 """get the normalized file extension, can be blank string but never None"""
1663 if isinstance(filename, bytes):
1664 filename = filename.decode('utf-8')
1665 return os.path.splitext(filename)[1].lower()[1:]
1668 def get_apk_debuggable_aapt(apkfile):
1669 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1671 if p.returncode != 0:
1672 raise FDroidException("Failed to get apk manifest information")
1673 for line in p.output.splitlines():
1674 if 'android:debuggable' in line and not line.endswith('0x0'):
1679 def get_apk_debuggable_androguard(apkfile):
1681 from androguard.core.bytecodes.apk import APK
1683 raise FDroidException("androguard library is not installed and aapt not present")
1685 apkobject = APK(apkfile)
1686 if apkobject.is_valid_APK():
1687 debuggable = apkobject.get_element("application", "debuggable")
1688 if debuggable is not None:
1689 return bool(strtobool(debuggable))
1693 def isApkAndDebuggable(apkfile):
1694 """Returns True if the given file is an APK and is debuggable
1696 :param apkfile: full path to the apk to check"""
1698 if get_file_extension(apkfile) != 'apk':
1701 if SdkToolsPopen(['aapt', 'version'], output=False):
1702 return get_apk_debuggable_aapt(apkfile)
1704 return get_apk_debuggable_androguard(apkfile)
1709 self.returncode = None
1713 def SdkToolsPopen(commands, cwd=None, output=True):
1715 if cmd not in config:
1716 config[cmd] = find_sdk_tools_cmd(commands[0])
1717 abscmd = config[cmd]
1719 raise FDroidException("Could not find '%s' on your system" % cmd)
1721 test_aapt_version(config['aapt'])
1722 return FDroidPopen([abscmd] + commands[1:],
1723 cwd=cwd, output=output)
1726 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1728 Run a command and capture the possibly huge output as bytes.
1730 :param commands: command and argument list like in subprocess.Popen
1731 :param cwd: optionally specifies a working directory
1732 :param envs: a optional dictionary of environment variables and their values
1733 :returns: A PopenResult.
1738 set_FDroidPopen_env()
1740 process_env = env.copy()
1741 if envs is not None and len(envs) > 0:
1742 process_env.update(envs)
1745 cwd = os.path.normpath(cwd)
1746 logging.debug("Directory: %s" % cwd)
1747 logging.debug("> %s" % ' '.join(commands))
1749 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1750 result = PopenResult()
1753 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1754 stdout=subprocess.PIPE, stderr=stderr_param)
1755 except OSError as e:
1756 raise BuildException("OSError while trying to execute " +
1757 ' '.join(commands) + ': ' + str(e))
1759 if not stderr_to_stdout and options.verbose:
1760 stderr_queue = Queue()
1761 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1763 while not stderr_reader.eof():
1764 while not stderr_queue.empty():
1765 line = stderr_queue.get()
1766 sys.stderr.buffer.write(line)
1771 stdout_queue = Queue()
1772 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1775 # Check the queue for output (until there is no more to get)
1776 while not stdout_reader.eof():
1777 while not stdout_queue.empty():
1778 line = stdout_queue.get()
1779 if output and options.verbose:
1780 # Output directly to console
1781 sys.stderr.buffer.write(line)
1787 result.returncode = p.wait()
1788 result.output = buf.getvalue()
1793 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1795 Run a command and capture the possibly huge output as a str.
1797 :param commands: command and argument list like in subprocess.Popen
1798 :param cwd: optionally specifies a working directory
1799 :param envs: a optional dictionary of environment variables and their values
1800 :returns: A PopenResult.
1802 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1803 result.output = result.output.decode('utf-8', 'ignore')
1807 gradle_comment = re.compile(r'[ ]*//')
1808 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1809 gradle_line_matches = [
1810 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1811 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1812 re.compile(r'.*\.readLine\(.*'),
1816 def remove_signing_keys(build_dir):
1817 for root, dirs, files in os.walk(build_dir):
1818 if 'build.gradle' in files:
1819 path = os.path.join(root, 'build.gradle')
1821 with open(path, "r", encoding='utf8') as o:
1822 lines = o.readlines()
1828 with open(path, "w", encoding='utf8') as o:
1829 while i < len(lines):
1832 while line.endswith('\\\n'):
1833 line = line.rstrip('\\\n') + lines[i]
1836 if gradle_comment.match(line):
1841 opened += line.count('{')
1842 opened -= line.count('}')
1845 if gradle_signing_configs.match(line):
1850 if any(s.match(line) for s in gradle_line_matches):
1858 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1861 'project.properties',
1863 'default.properties',
1864 'ant.properties', ]:
1865 if propfile in files:
1866 path = os.path.join(root, propfile)
1868 with open(path, "r", encoding='iso-8859-1') as o:
1869 lines = o.readlines()
1873 with open(path, "w", encoding='iso-8859-1') as o:
1875 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1882 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1885 def set_FDroidPopen_env(build=None):
1887 set up the environment variables for the build environment
1889 There is only a weak standard, the variables used by gradle, so also set
1890 up the most commonly used environment variables for SDK and NDK. Also, if
1891 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1893 global env, orig_path
1897 orig_path = env['PATH']
1898 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1899 env[n] = config['sdk_path']
1900 for k, v in config['java_paths'].items():
1901 env['JAVA%s_HOME' % k] = v
1903 missinglocale = True
1904 for k, v in env.items():
1905 if k == 'LANG' and v != 'C':
1906 missinglocale = False
1908 missinglocale = False
1910 env['LANG'] = 'en_US.UTF-8'
1912 if build is not None:
1913 path = build.ndk_path()
1914 paths = orig_path.split(os.pathsep)
1915 if path not in paths:
1916 paths = [path] + paths
1917 env['PATH'] = os.pathsep.join(paths)
1918 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1919 env[n] = build.ndk_path()
1922 def replace_build_vars(cmd, build):
1923 cmd = cmd.replace('$$COMMIT$$', build.commit)
1924 cmd = cmd.replace('$$VERSION$$', build.versionName)
1925 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1929 def replace_config_vars(cmd, build):
1930 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1931 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1932 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1933 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1934 if build is not None:
1935 cmd = replace_build_vars(cmd, build)
1939 def place_srclib(root_dir, number, libpath):
1942 relpath = os.path.relpath(libpath, root_dir)
1943 proppath = os.path.join(root_dir, 'project.properties')
1946 if os.path.isfile(proppath):
1947 with open(proppath, "r", encoding='iso-8859-1') as o:
1948 lines = o.readlines()
1950 with open(proppath, "w", encoding='iso-8859-1') as o:
1953 if line.startswith('android.library.reference.%d=' % number):
1954 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1959 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1962 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1965 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1966 """Verify that two apks are the same
1968 One of the inputs is signed, the other is unsigned. The signature metadata
1969 is transferred from the signed to the unsigned apk, and then jarsigner is
1970 used to verify that the signature from the signed apk is also varlid for
1971 the unsigned one. If the APK given as unsigned actually does have a
1972 signature, it will be stripped out and ignored.
1974 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1975 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1976 into AndroidManifest.xml, but that makes the build not reproducible. So
1977 instead they are included as separate files in the APK's META-INF/ folder.
1978 If those files exist in the signed APK, they will be part of the signature
1979 and need to also be included in the unsigned APK for it to validate.
1981 :param signed_apk: Path to a signed apk file
1982 :param unsigned_apk: Path to an unsigned apk file expected to match it
1983 :param tmp_dir: Path to directory for temporary files
1984 :returns: None if the verification is successful, otherwise a string
1985 describing what went wrong.
1988 signed = ZipFile(signed_apk, 'r')
1989 meta_inf_files = ['META-INF/MANIFEST.MF']
1990 for f in signed.namelist():
1991 if apk_sigfile.match(f) \
1992 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
1993 meta_inf_files.append(f)
1994 if len(meta_inf_files) < 3:
1995 return "Signature files missing from {0}".format(signed_apk)
1997 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
1998 unsigned = ZipFile(unsigned_apk, 'r')
1999 # only read the signature from the signed APK, everything else from unsigned
2000 with ZipFile(tmp_apk, 'w') as tmp:
2001 for filename in meta_inf_files:
2002 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2003 for info in unsigned.infolist():
2004 if info.filename in meta_inf_files:
2005 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2007 if info.filename in tmp.namelist():
2008 return "duplicate filename found: " + info.filename
2009 tmp.writestr(info, unsigned.read(info.filename))
2013 verified = verify_apk_signature(tmp_apk)
2016 logging.info("...NOT verified - {0}".format(tmp_apk))
2017 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2018 os.path.dirname(unsigned_apk))
2020 logging.info("...successfully verified")
2024 def verify_apk_signature(apk, jar=False):
2025 """verify the signature on an APK
2027 Try to use apksigner whenever possible since jarsigner is very
2028 shitty: unsigned APKs pass as "verified"! So this has to turn on
2029 -strict then check for result 4.
2031 You can set :param: jar to True if you want to use this method
2032 to verify jar signatures.
2034 if set_command_in_config('apksigner'):
2035 args = [config['apksigner'], 'verify']
2037 args += ['--min-sdk-version=1']
2038 return subprocess.call(args + [apk]) == 0
2040 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2041 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2044 apk_badchars = re.compile('''[/ :;'"]''')
2047 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2050 Returns None if the apk content is the same (apart from the signing key),
2051 otherwise a string describing what's different, or what went wrong when
2052 trying to do the comparison.
2058 absapk1 = os.path.abspath(apk1)
2059 absapk2 = os.path.abspath(apk2)
2061 if set_command_in_config('diffoscope'):
2062 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2063 htmlfile = logfilename + '.diffoscope.html'
2064 textfile = logfilename + '.diffoscope.txt'
2065 if subprocess.call([config['diffoscope'],
2066 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2067 '--html', htmlfile, '--text', textfile,
2068 absapk1, absapk2]) != 0:
2069 return("Failed to unpack " + apk1)
2071 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2072 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2073 for d in [apk1dir, apk2dir]:
2074 if os.path.exists(d):
2077 os.mkdir(os.path.join(d, 'jar-xf'))
2079 if subprocess.call(['jar', 'xf',
2080 os.path.abspath(apk1)],
2081 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2082 return("Failed to unpack " + apk1)
2083 if subprocess.call(['jar', 'xf',
2084 os.path.abspath(apk2)],
2085 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2086 return("Failed to unpack " + apk2)
2088 if set_command_in_config('apktool'):
2089 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2091 return("Failed to unpack " + apk1)
2092 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2094 return("Failed to unpack " + apk2)
2096 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2097 lines = p.output.splitlines()
2098 if len(lines) != 1 or 'META-INF' not in lines[0]:
2099 if set_command_in_config('meld'):
2100 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2101 return("Unexpected diff output - " + p.output)
2103 # since everything verifies, delete the comparison to keep cruft down
2104 shutil.rmtree(apk1dir)
2105 shutil.rmtree(apk2dir)
2107 # If we get here, it seems like they're the same!
2111 def set_command_in_config(command):
2112 '''Try to find specified command in the path, if it hasn't been
2113 manually set in config.py. If found, it is added to the config
2114 dict. The return value says whether the command is available.
2117 if command in config:
2120 tmp = find_command(command)
2122 config[command] = tmp
2127 def find_command(command):
2128 '''find the full path of a command, or None if it can't be found in the PATH'''
2131 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2133 fpath, fname = os.path.split(command)
2138 for path in os.environ["PATH"].split(os.pathsep):
2139 path = path.strip('"')
2140 exe_file = os.path.join(path, command)
2141 if is_exe(exe_file):
2148 '''generate a random password for when generating keys'''
2149 h = hashlib.sha256()
2150 h.update(os.urandom(16)) # salt
2151 h.update(socket.getfqdn().encode('utf-8'))
2152 passwd = base64.b64encode(h.digest()).strip()
2153 return passwd.decode('utf-8')
2156 def genkeystore(localconfig):
2158 Generate a new key with password provided in :param localconfig and add it to new keystore
2159 :return: hexed public key, public key fingerprint
2161 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2162 keystoredir = os.path.dirname(localconfig['keystore'])
2163 if keystoredir is None or keystoredir == '':
2164 keystoredir = os.path.join(os.getcwd(), keystoredir)
2165 if not os.path.exists(keystoredir):
2166 os.makedirs(keystoredir, mode=0o700)
2169 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2170 'FDROID_KEY_PASS': localconfig['keypass'],
2172 p = FDroidPopen([config['keytool'], '-genkey',
2173 '-keystore', localconfig['keystore'],
2174 '-alias', localconfig['repo_keyalias'],
2175 '-keyalg', 'RSA', '-keysize', '4096',
2176 '-sigalg', 'SHA256withRSA',
2177 '-validity', '10000',
2178 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2179 '-keypass:env', 'FDROID_KEY_PASS',
2180 '-dname', localconfig['keydname']], envs=env_vars)
2181 if p.returncode != 0:
2182 raise BuildException("Failed to generate key", p.output)
2183 os.chmod(localconfig['keystore'], 0o0600)
2184 if not options.quiet:
2185 # now show the lovely key that was just generated
2186 p = FDroidPopen([config['keytool'], '-list', '-v',
2187 '-keystore', localconfig['keystore'],
2188 '-alias', localconfig['repo_keyalias'],
2189 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2190 logging.info(p.output.strip() + '\n\n')
2191 # get the public key
2192 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2193 '-keystore', localconfig['keystore'],
2194 '-alias', localconfig['repo_keyalias'],
2195 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2196 + config['smartcardoptions'],
2197 envs=env_vars, output=False, stderr_to_stdout=False)
2198 if p.returncode != 0 or len(p.output) < 20:
2199 raise BuildException("Failed to get public key", p.output)
2201 fingerprint = get_cert_fingerprint(pubkey)
2202 return hexlify(pubkey), fingerprint
2205 def get_cert_fingerprint(pubkey):
2207 Generate a certificate fingerprint the same way keytool does it
2208 (but with slightly different formatting)
2210 digest = hashlib.sha256(pubkey).digest()
2211 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2212 return " ".join(ret)
2215 def get_certificate(certificate_file):
2217 Extracts a certificate from the given file.
2218 :param certificate_file: file bytes (as string) representing the certificate
2219 :return: A binary representation of the certificate's public key, or None in case of error
2221 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2222 if content.getComponentByName('contentType') != rfc2315.signedData:
2224 content = decoder.decode(content.getComponentByName('content'),
2225 asn1Spec=rfc2315.SignedData())[0]
2227 certificates = content.getComponentByName('certificates')
2228 cert = certificates[0].getComponentByName('certificate')
2230 logging.error("Certificates not found.")
2232 return encoder.encode(cert)
2235 def write_to_config(thisconfig, key, value=None, config_file=None):
2236 '''write a key/value to the local config.py
2238 NOTE: only supports writing string variables.
2240 :param thisconfig: config dictionary
2241 :param key: variable name in config.py to be overwritten/added
2242 :param value: optional value to be written, instead of fetched
2243 from 'thisconfig' dictionary.
2246 origkey = key + '_orig'
2247 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2248 cfg = config_file if config_file else 'config.py'
2251 with open(cfg, 'r', encoding="utf-8") as f:
2252 lines = f.readlines()
2254 # make sure the file ends with a carraige return
2256 if not lines[-1].endswith('\n'):
2259 # regex for finding and replacing python string variable
2260 # definitions/initializations
2261 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2262 repl = key + ' = "' + value + '"'
2263 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2264 repl2 = key + " = '" + value + "'"
2266 # If we replaced this line once, we make sure won't be a
2267 # second instance of this line for this key in the document.
2270 with open(cfg, 'w', encoding="utf-8") as f:
2272 if pattern.match(line) or pattern2.match(line):
2274 line = pattern.sub(repl, line)
2275 line = pattern2.sub(repl2, line)
2286 def parse_xml(path):
2287 return XMLElementTree.parse(path).getroot()
2290 def string_is_integer(string):
2298 def get_per_app_repos():
2299 '''per-app repos are dirs named with the packageName of a single app'''
2301 # Android packageNames are Java packages, they may contain uppercase or
2302 # lowercase letters ('A' through 'Z'), numbers, and underscores
2303 # ('_'). However, individual package name parts may only start with
2304 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2305 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2308 for root, dirs, files in os.walk(os.getcwd()):
2310 print('checking', root, 'for', d)
2311 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2312 # standard parts of an fdroid repo, so never packageNames
2315 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2321 def is_repo_file(filename):
2322 '''Whether the file in a repo is a build product to be delivered to users'''
2323 if isinstance(filename, str):
2324 filename = filename.encode('utf-8', errors="surrogateescape")
2325 return os.path.isfile(filename) \
2326 and not filename.endswith(b'.asc') \
2327 and not filename.endswith(b'.sig') \
2328 and os.path.basename(filename) not in [
2330 b'index_unsigned.jar',