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 import _
53 from fdroidserver.exception import FDroidException, VCSException, BuildException, VerificationException
54 from .asynchronousfilereader import AsynchronousFileReader
57 # A signature block file with a .DSA, .RSA, or .EC extension
58 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
59 APK_NAME_REGEX = re.compile(r'^([a-zA-Z][\w.]*)_(-?[0-9]+)_?([0-9a-f]{7})?\.apk')
60 STANDARD_FILE_NAME_REGEX = re.compile(r'^(\w[\w.]*)_(-?[0-9]+)\.\w+')
62 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
71 'sdk_path': "$ANDROID_HOME",
76 'r12b': "$ANDROID_NDK",
82 'build_tools': "25.0.2",
83 'force_build_tools': False,
88 'accepted_formats': ['txt', 'yml'],
89 'sync_from_local_copy_dir': False,
90 'allow_disabled_algorithms': False,
91 'per_app_repos': False,
92 'make_current_version_link': True,
93 'current_version_name_source': 'Name',
94 'update_stats': False,
98 'stats_to_carbon': False,
100 'build_server_always': False,
101 'keystore': 'keystore.jks',
102 'smartcardoptions': [],
112 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
113 'repo_name': "My First FDroid Repo Demo",
114 'repo_icon': "fdroid-icon.png",
115 'repo_description': '''
116 This is a repository of apps to be used with FDroid. Applications in this
117 repository are either official binaries built by the original application
118 developers, or are binaries built from source by the admin of f-droid.org
119 using the tools on https://gitlab.com/u/fdroid.
125 def setup_global_opts(parser):
126 parser.add_argument("-v", "--verbose", action="store_true", default=False,
127 help=_("Spew out even more information than normal"))
128 parser.add_argument("-q", "--quiet", action="store_true", default=False,
129 help=_("Restrict output to warnings and errors"))
132 def fill_config_defaults(thisconfig):
133 for k, v in default_config.items():
134 if k not in thisconfig:
137 # Expand paths (~users and $vars)
138 def expand_path(path):
142 path = os.path.expanduser(path)
143 path = os.path.expandvars(path)
148 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
153 thisconfig[k + '_orig'] = v
155 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
156 if thisconfig['java_paths'] is None:
157 thisconfig['java_paths'] = dict()
159 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
160 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
161 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
162 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
163 if os.getenv('JAVA_HOME') is not None:
164 pathlist.append(os.getenv('JAVA_HOME'))
165 if os.getenv('PROGRAMFILES') is not None:
166 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
167 for d in sorted(pathlist):
168 if os.path.islink(d):
170 j = os.path.basename(d)
171 # the last one found will be the canonical one, so order appropriately
173 r'^1\.([6-9])\.0\.jdk$', # OSX
174 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
175 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
176 r'^jdk([6-9])-openjdk$', # Arch
177 r'^java-([6-9])-openjdk$', # Arch
178 r'^java-([6-9])-jdk$', # Arch (oracle)
179 r'^java-1\.([6-9])\.0-.*$', # RedHat
180 r'^java-([6-9])-oracle$', # Debian WebUpd8
181 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
182 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
184 m = re.match(regex, j)
187 for p in [d, os.path.join(d, 'Contents', 'Home')]:
188 if os.path.exists(os.path.join(p, 'bin', 'javac')):
189 thisconfig['java_paths'][m.group(1)] = p
191 for java_version in ('7', '8', '9'):
192 if java_version not in thisconfig['java_paths']:
194 java_home = thisconfig['java_paths'][java_version]
195 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
196 if os.path.exists(jarsigner):
197 thisconfig['jarsigner'] = jarsigner
198 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
199 break # Java7 is preferred, so quit if found
201 for k in ['ndk_paths', 'java_paths']:
207 thisconfig[k][k2] = exp
208 thisconfig[k][k2 + '_orig'] = v
211 def regsub_file(pattern, repl, path):
212 with open(path, 'rb') as f:
214 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
215 with open(path, 'wb') as f:
219 def read_config(opts, config_file='config.py'):
220 """Read the repository config
222 The config is read from config_file, which is in the current
223 directory when any of the repo management commands are used. If
224 there is a local metadata file in the git repo, then config.py is
225 not required, just use defaults.
228 global config, options
230 if config is not None:
237 if os.path.isfile(config_file):
238 logging.debug(_("Reading '{config_file}'").format(config_file=config_file))
239 with io.open(config_file, "rb") as f:
240 code = compile(f.read(), config_file, 'exec')
241 exec(code, None, config)
243 logging.warning(_("No 'config.py' found, using defaults."))
245 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
247 if not type(config[k]) in (str, list, tuple):
249 _("'{field}' will be in random order! Use () or [] brackets if order is important!")
252 # smartcardoptions must be a list since its command line args for Popen
253 if 'smartcardoptions' in config:
254 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
255 elif 'keystore' in config and config['keystore'] == 'NONE':
256 # keystore='NONE' means use smartcard, these are required defaults
257 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
258 'SunPKCS11-OpenSC', '-providerClass',
259 'sun.security.pkcs11.SunPKCS11',
260 '-providerArg', 'opensc-fdroid.cfg']
262 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
263 st = os.stat(config_file)
264 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
265 logging.warning(_("unsafe permissions on '{config_file}' (should be 0600)!")
266 .format(config_file=config_file))
268 fill_config_defaults(config)
270 for k in ["repo_description", "archive_description"]:
272 config[k] = clean_description(config[k])
274 if 'serverwebroot' in config:
275 if isinstance(config['serverwebroot'], str):
276 roots = [config['serverwebroot']]
277 elif all(isinstance(item, str) for item in config['serverwebroot']):
278 roots = config['serverwebroot']
280 raise TypeError(_('only accepts strings, lists, and tuples'))
282 for rootstr in roots:
283 # since this is used with rsync, where trailing slashes have
284 # meaning, ensure there is always a trailing slash
285 if rootstr[-1] != '/':
287 rootlist.append(rootstr.replace('//', '/'))
288 config['serverwebroot'] = rootlist
290 if 'servergitmirrors' in config:
291 if isinstance(config['servergitmirrors'], str):
292 roots = [config['servergitmirrors']]
293 elif all(isinstance(item, str) for item in config['servergitmirrors']):
294 roots = config['servergitmirrors']
296 raise TypeError(_('only accepts strings, lists, and tuples'))
297 config['servergitmirrors'] = roots
302 def find_sdk_tools_cmd(cmd):
303 '''find a working path to a tool from the Android SDK'''
306 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
307 # try to find a working path to this command, in all the recent possible paths
308 if 'build_tools' in config:
309 build_tools = os.path.join(config['sdk_path'], 'build-tools')
310 # if 'build_tools' was manually set and exists, check only that one
311 configed_build_tools = os.path.join(build_tools, config['build_tools'])
312 if os.path.exists(configed_build_tools):
313 tooldirs.append(configed_build_tools)
315 # no configed version, so hunt known paths for it
316 for f in sorted(os.listdir(build_tools), reverse=True):
317 if os.path.isdir(os.path.join(build_tools, f)):
318 tooldirs.append(os.path.join(build_tools, f))
319 tooldirs.append(build_tools)
320 sdk_tools = os.path.join(config['sdk_path'], 'tools')
321 if os.path.exists(sdk_tools):
322 tooldirs.append(sdk_tools)
323 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
324 if os.path.exists(sdk_platform_tools):
325 tooldirs.append(sdk_platform_tools)
326 tooldirs.append('/usr/bin')
328 path = os.path.join(d, cmd)
329 if os.path.isfile(path):
331 test_aapt_version(path)
333 # did not find the command, exit with error message
334 ensure_build_tools_exists(config)
337 def test_aapt_version(aapt):
338 '''Check whether the version of aapt is new enough'''
339 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
340 if output is None or output == '':
341 logging.error(_("'{path}' failed to execute!").format(path=aapt))
343 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
348 # the Debian package has the version string like "v0.2-23.0.2"
349 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
350 logging.warning(_("'{aapt}' is too old, fdroid requires build-tools-23.0.0 or newer!")
353 logging.warning(_('Unknown version of aapt, might cause problems: ') + output)
356 def test_sdk_exists(thisconfig):
357 if 'sdk_path' not in thisconfig:
358 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
359 test_aapt_version(thisconfig['aapt'])
362 logging.error(_("'sdk_path' not set in 'config.py'!"))
364 if thisconfig['sdk_path'] == default_config['sdk_path']:
365 logging.error(_('No Android SDK found!'))
366 logging.error(_('You can use ANDROID_HOME to set the path to your SDK, i.e.:'))
367 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
369 if not os.path.exists(thisconfig['sdk_path']):
370 logging.critical(_("Android SDK path '{path}' does not exist!")
371 .format(path=thisconfig['sdk_path']))
373 if not os.path.isdir(thisconfig['sdk_path']):
374 logging.critical(_("Android SDK path '{path}' is not a directory!")
375 .format(path=thisconfig['sdk_path']))
377 for d in ['build-tools', 'platform-tools', 'tools']:
378 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
379 logging.critical(_("Android SDK '{path}' does not have '{dirname}' installed!")
380 .format(path=thisconfig['sdk_path'], dirname=d))
385 def ensure_build_tools_exists(thisconfig):
386 if not test_sdk_exists(thisconfig):
387 raise FDroidException(_("Android SDK not found!"))
388 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
389 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
390 if not os.path.isdir(versioned_build_tools):
391 raise FDroidException(
392 _("Android Build Tools path '{path}' does not exist!")
393 .format(path=versioned_build_tools))
396 def get_local_metadata_files():
397 '''get any metadata files local to an app's source repo
399 This tries to ignore anything that does not count as app metdata,
400 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
403 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
406 def read_pkg_args(args, allow_vercodes=False):
408 :param args: arguments in the form of multiple appid:[vc] strings
409 :returns: a dictionary with the set of vercodes specified for each package
417 if allow_vercodes and ':' in p:
418 package, vercode = p.split(':')
420 package, vercode = p, None
421 if package not in vercodes:
422 vercodes[package] = [vercode] if vercode else []
424 elif vercode and vercode not in vercodes[package]:
425 vercodes[package] += [vercode] if vercode else []
430 def read_app_args(args, allapps, allow_vercodes=False):
432 On top of what read_pkg_args does, this returns the whole app metadata, but
433 limiting the builds list to the builds matching the vercodes specified.
436 vercodes = read_pkg_args(args, allow_vercodes)
442 for appid, app in allapps.items():
443 if appid in vercodes:
446 if len(apps) != len(vercodes):
449 logging.critical(_("No such package: %s") % p)
450 raise FDroidException(_("Found invalid appids in arguments"))
452 raise FDroidException(_("No packages specified"))
455 for appid, app in apps.items():
459 app.builds = [b for b in app.builds if b.versionCode in vc]
460 if len(app.builds) != len(vercodes[appid]):
462 allvcs = [b.versionCode for b in app.builds]
463 for v in vercodes[appid]:
465 logging.critical(_("No such versionCode {versionCode} for app {appid}")
466 .format(versionCode=v, appid=appid))
469 raise FDroidException(_("Found invalid versionCodes for some apps"))
474 def get_extension(filename):
475 base, ext = os.path.splitext(filename)
478 return base, ext.lower()[1:]
481 def has_extension(filename, ext):
482 _, f_ext = get_extension(filename)
486 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
489 def clean_description(description):
490 'Remove unneeded newlines and spaces from a block of description text'
492 # this is split up by paragraph to make removing the newlines easier
493 for paragraph in re.split(r'\n\n', description):
494 paragraph = re.sub('\r', '', paragraph)
495 paragraph = re.sub('\n', ' ', paragraph)
496 paragraph = re.sub(' {2,}', ' ', paragraph)
497 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
498 returnstring += paragraph + '\n\n'
499 return returnstring.rstrip('\n')
502 def publishednameinfo(filename):
503 filename = os.path.basename(filename)
504 m = publish_name_regex.match(filename)
506 result = (m.group(1), m.group(2))
507 except AttributeError:
508 raise FDroidException(_("Invalid name for published file: %s") % filename)
512 def get_release_filename(app, build):
514 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
516 return "%s_%s.apk" % (app.id, build.versionCode)
519 def get_toolsversion_logname(app, build):
520 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
523 def getsrcname(app, build):
524 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
536 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
539 def get_build_dir(app):
540 '''get the dir that this app will be built in'''
542 if app.RepoType == 'srclib':
543 return os.path.join('build', 'srclib', app.Repo)
545 return os.path.join('build', app.id)
549 '''checkout code from VCS and return instance of vcs and the build dir'''
550 build_dir = get_build_dir(app)
552 # Set up vcs interface and make sure we have the latest code...
553 logging.debug("Getting {0} vcs interface for {1}"
554 .format(app.RepoType, app.Repo))
555 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
559 vcs = getvcs(app.RepoType, remote, build_dir)
561 return vcs, build_dir
564 def getvcs(vcstype, remote, local):
566 return vcs_git(remote, local)
567 if vcstype == 'git-svn':
568 return vcs_gitsvn(remote, local)
570 return vcs_hg(remote, local)
572 return vcs_bzr(remote, local)
573 if vcstype == 'srclib':
574 if local != os.path.join('build', 'srclib', remote):
575 raise VCSException("Error: srclib paths are hard-coded!")
576 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
578 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
579 raise VCSException("Invalid vcs type " + vcstype)
582 def getsrclibvcs(name):
583 if name not in fdroidserver.metadata.srclibs:
584 raise VCSException("Missing srclib " + name)
585 return fdroidserver.metadata.srclibs[name]['Repo Type']
590 def __init__(self, remote, local):
592 # svn, git-svn and bzr may require auth
594 if self.repotype() in ('git-svn', 'bzr'):
596 if self.repotype == 'git-svn':
597 raise VCSException("Authentication is not supported for git-svn")
598 self.username, remote = remote.split('@')
599 if ':' not in self.username:
600 raise VCSException(_("Password required with username"))
601 self.username, self.password = self.username.split(':')
605 self.clone_failed = False
606 self.refreshed = False
612 def gotorevision(self, rev, refresh=True):
613 """Take the local repository to a clean version of the given
614 revision, which is specificed in the VCS's native
615 format. Beforehand, the repository can be dirty, or even
616 non-existent. If the repository does already exist locally, it
617 will be updated from the origin, but only once in the lifetime
618 of the vcs object. None is acceptable for 'rev' if you know
619 you are cloning a clean copy of the repo - otherwise it must
620 specify a valid revision.
623 if self.clone_failed:
624 raise VCSException(_("Downloading the repository already failed once, not trying again."))
626 # The .fdroidvcs-id file for a repo tells us what VCS type
627 # and remote that directory was created from, allowing us to drop it
628 # automatically if either of those things changes.
629 fdpath = os.path.join(self.local, '..',
630 '.fdroidvcs-' + os.path.basename(self.local))
631 fdpath = os.path.normpath(fdpath)
632 cdata = self.repotype() + ' ' + self.remote
635 if os.path.exists(self.local):
636 if os.path.exists(fdpath):
637 with open(fdpath, 'r') as f:
638 fsdata = f.read().strip()
643 logging.info("Repository details for %s changed - deleting" % (
647 logging.info("Repository details for %s missing - deleting" % (
650 shutil.rmtree(self.local)
654 self.refreshed = True
657 self.gotorevisionx(rev)
658 except FDroidException as e:
661 # If necessary, write the .fdroidvcs file.
662 if writeback and not self.clone_failed:
663 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
664 with open(fdpath, 'w+') as f:
670 def gotorevisionx(self, rev): # pylint: disable=unused-argument
671 """Derived classes need to implement this.
673 It's called once basic checking has been performed.
675 raise VCSException("This VCS type doesn't define gotorevisionx")
677 # Initialise and update submodules
678 def initsubmodules(self):
679 raise VCSException('Submodules not supported for this vcs type')
681 # Get a list of all known tags
683 if not self._gettags:
684 raise VCSException('gettags not supported for this vcs type')
686 for tag in self._gettags():
687 if re.match('[-A-Za-z0-9_. /]+$', tag):
691 def latesttags(self):
692 """Get a list of all the known tags, sorted from newest to oldest"""
693 raise VCSException('latesttags not supported for this vcs type')
696 """Get current commit reference (hash, revision, etc)"""
697 raise VCSException('getref not supported for this vcs type')
700 """Returns the srclib (name, path) used in setting up the current revision, or None."""
710 """If the local directory exists, but is somehow not a git repository,
711 git will traverse up the directory tree until it finds one
712 that is (i.e. fdroidserver) and then we'll proceed to destroy
713 it! This is called as a safety check.
717 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
718 result = p.output.rstrip()
719 if not result.endswith(self.local):
720 raise VCSException('Repository mismatch')
722 def gotorevisionx(self, rev):
723 if not os.path.exists(self.local):
725 p = FDroidPopen(['git', 'clone', self.remote, self.local])
726 if p.returncode != 0:
727 self.clone_failed = True
728 raise VCSException("Git clone failed", p.output)
732 # Discard any working tree changes
733 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
734 'git', 'reset', '--hard'], cwd=self.local, output=False)
735 if p.returncode != 0:
736 raise VCSException(_("Git reset failed"), p.output)
737 # Remove untracked files now, in case they're tracked in the target
738 # revision (it happens!)
739 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
740 'git', 'clean', '-dffx'], cwd=self.local, output=False)
741 if p.returncode != 0:
742 raise VCSException(_("Git clean failed"), p.output)
743 if not self.refreshed:
744 # Get latest commits and tags from remote
745 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
746 if p.returncode != 0:
747 raise VCSException(_("Git fetch failed"), p.output)
748 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
749 if p.returncode != 0:
750 raise VCSException(_("Git fetch failed"), p.output)
751 # Recreate origin/HEAD as git clone would do it, in case it disappeared
752 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
753 if p.returncode != 0:
754 lines = p.output.splitlines()
755 if 'Multiple remote HEAD branches' not in lines[0]:
756 raise VCSException(_("Git remote set-head failed"), p.output)
757 branch = lines[1].split(' ')[-1]
758 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
759 if p2.returncode != 0:
760 raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
761 self.refreshed = True
762 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
763 # a github repo. Most of the time this is the same as origin/master.
764 rev = rev or 'origin/HEAD'
765 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
766 if p.returncode != 0:
767 raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
768 # Get rid of any uncontrolled files left behind
769 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
770 if p.returncode != 0:
771 raise VCSException(_("Git clean failed"), p.output)
773 def initsubmodules(self):
775 submfile = os.path.join(self.local, '.gitmodules')
776 if not os.path.isfile(submfile):
777 raise VCSException(_("No git submodules available"))
779 # fix submodules not accessible without an account and public key auth
780 with open(submfile, 'r') as f:
781 lines = f.readlines()
782 with open(submfile, 'w') as f:
784 if 'git@github.com' in line:
785 line = line.replace('git@github.com:', 'https://github.com/')
786 if 'git@gitlab.com' in line:
787 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
790 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
791 if p.returncode != 0:
792 raise VCSException(_("Git submodule sync failed"), p.output)
793 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
794 if p.returncode != 0:
795 raise VCSException(_("Git submodule update failed"), p.output)
799 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
800 return p.output.splitlines()
802 tag_format = re.compile(r'tag: ([^),]*)')
804 def latesttags(self):
806 p = FDroidPopen(['git', 'log', '--tags',
807 '--simplify-by-decoration', '--pretty=format:%d'],
808 cwd=self.local, output=False)
810 for line in p.output.splitlines():
811 for tag in self.tag_format.findall(line):
816 class vcs_gitsvn(vcs):
822 """If the local directory exists, but is somehow not a git repository,
823 git will traverse up the directory tree until it finds one that
824 is (i.e. fdroidserver) and then we'll proceed to destory it!
825 This is called as a safety check.
828 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
829 result = p.output.rstrip()
830 if not result.endswith(self.local):
831 raise VCSException('Repository mismatch')
833 def gotorevisionx(self, rev):
834 if not os.path.exists(self.local):
836 gitsvn_args = ['git', 'svn', 'clone']
837 if ';' in self.remote:
838 remote_split = self.remote.split(';')
839 for i in remote_split[1:]:
840 if i.startswith('trunk='):
841 gitsvn_args.extend(['-T', i[6:]])
842 elif i.startswith('tags='):
843 gitsvn_args.extend(['-t', i[5:]])
844 elif i.startswith('branches='):
845 gitsvn_args.extend(['-b', i[9:]])
846 gitsvn_args.extend([remote_split[0], self.local])
847 p = FDroidPopen(gitsvn_args, output=False)
848 if p.returncode != 0:
849 self.clone_failed = True
850 raise VCSException("Git svn clone failed", p.output)
852 gitsvn_args.extend([self.remote, self.local])
853 p = FDroidPopen(gitsvn_args, output=False)
854 if p.returncode != 0:
855 self.clone_failed = True
856 raise VCSException("Git svn clone failed", p.output)
860 # Discard any working tree changes
861 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
862 if p.returncode != 0:
863 raise VCSException("Git reset failed", p.output)
864 # Remove untracked files now, in case they're tracked in the target
865 # revision (it happens!)
866 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
867 if p.returncode != 0:
868 raise VCSException("Git clean failed", p.output)
869 if not self.refreshed:
870 # Get new commits, branches and tags from repo
871 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
872 if p.returncode != 0:
873 raise VCSException("Git svn fetch failed")
874 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
875 if p.returncode != 0:
876 raise VCSException("Git svn rebase failed", p.output)
877 self.refreshed = True
879 rev = rev or 'master'
881 nospaces_rev = rev.replace(' ', '%20')
882 # Try finding a svn tag
883 for treeish in ['origin/', '']:
884 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
885 if p.returncode == 0:
887 if p.returncode != 0:
888 # No tag found, normal svn rev translation
889 # Translate svn rev into git format
890 rev_split = rev.split('/')
893 for treeish in ['origin/', '']:
894 if len(rev_split) > 1:
895 treeish += rev_split[0]
896 svn_rev = rev_split[1]
899 # if no branch is specified, then assume trunk (i.e. 'master' branch):
903 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
905 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
906 git_rev = p.output.rstrip()
908 if p.returncode == 0 and git_rev:
911 if p.returncode != 0 or not git_rev:
912 # Try a plain git checkout as a last resort
913 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
914 if p.returncode != 0:
915 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
917 # Check out the git rev equivalent to the svn rev
918 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
919 if p.returncode != 0:
920 raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
922 # Get rid of any uncontrolled files left behind
923 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
924 if p.returncode != 0:
925 raise VCSException(_("Git clean failed"), p.output)
929 for treeish in ['origin/', '']:
930 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
936 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
937 if p.returncode != 0:
939 return p.output.strip()
947 def gotorevisionx(self, rev):
948 if not os.path.exists(self.local):
949 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
950 if p.returncode != 0:
951 self.clone_failed = True
952 raise VCSException("Hg clone failed", p.output)
954 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
955 if p.returncode != 0:
956 raise VCSException("Hg status failed", p.output)
957 for line in p.output.splitlines():
958 if not line.startswith('? '):
959 raise VCSException("Unexpected output from hg status -uS: " + line)
960 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
961 if not self.refreshed:
962 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
963 if p.returncode != 0:
964 raise VCSException("Hg pull failed", p.output)
965 self.refreshed = True
967 rev = rev or 'default'
970 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
971 if p.returncode != 0:
972 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
973 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
974 # Also delete untracked files, we have to enable purge extension for that:
975 if "'purge' is provided by the following extension" in p.output:
976 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
977 myfile.write("\n[extensions]\nhgext.purge=\n")
978 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
979 if p.returncode != 0:
980 raise VCSException("HG purge failed", p.output)
981 elif p.returncode != 0:
982 raise VCSException("HG purge failed", p.output)
985 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
986 return p.output.splitlines()[1:]
994 def gotorevisionx(self, rev):
995 if not os.path.exists(self.local):
996 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
997 if p.returncode != 0:
998 self.clone_failed = True
999 raise VCSException("Bzr branch failed", p.output)
1001 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1002 if p.returncode != 0:
1003 raise VCSException("Bzr revert failed", p.output)
1004 if not self.refreshed:
1005 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1006 if p.returncode != 0:
1007 raise VCSException("Bzr update failed", p.output)
1008 self.refreshed = True
1010 revargs = list(['-r', rev] if rev else [])
1011 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1012 if p.returncode != 0:
1013 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1016 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1017 return [tag.split(' ')[0].strip() for tag in
1018 p.output.splitlines()]
1021 def unescape_string(string):
1024 if string[0] == '"' and string[-1] == '"':
1027 return string.replace("\\'", "'")
1030 def retrieve_string(app_dir, string, xmlfiles=None):
1032 if not string.startswith('@string/'):
1033 return unescape_string(string)
1035 if xmlfiles is None:
1038 os.path.join(app_dir, 'res'),
1039 os.path.join(app_dir, 'src', 'main', 'res'),
1041 for root, dirs, files in os.walk(res_dir):
1042 if os.path.basename(root) == 'values':
1043 xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1045 name = string[len('@string/'):]
1047 def element_content(element):
1048 if element.text is None:
1050 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1051 return s.decode('utf-8').strip()
1053 for path in xmlfiles:
1054 if not os.path.isfile(path):
1056 xml = parse_xml(path)
1057 element = xml.find('string[@name="' + name + '"]')
1058 if element is not None:
1059 content = element_content(element)
1060 return retrieve_string(app_dir, content, xmlfiles)
1065 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1066 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1069 def manifest_paths(app_dir, flavours):
1070 '''Return list of existing files that will be used to find the highest vercode'''
1072 possible_manifests = \
1073 [os.path.join(app_dir, 'AndroidManifest.xml'),
1074 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1075 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1076 os.path.join(app_dir, 'build.gradle')]
1078 for flavour in flavours:
1079 if flavour == 'yes':
1081 possible_manifests.append(
1082 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1084 return [path for path in possible_manifests if os.path.isfile(path)]
1087 def fetch_real_name(app_dir, flavours):
1088 '''Retrieve the package name. Returns the name, or None if not found.'''
1089 for path in manifest_paths(app_dir, flavours):
1090 if not has_extension(path, 'xml') or not os.path.isfile(path):
1092 logging.debug("fetch_real_name: Checking manifest at " + path)
1093 xml = parse_xml(path)
1094 app = xml.find('application')
1097 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1099 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1100 result = retrieve_string_singleline(app_dir, label)
1102 result = result.strip()
1107 def get_library_references(root_dir):
1109 proppath = os.path.join(root_dir, 'project.properties')
1110 if not os.path.isfile(proppath):
1112 with open(proppath, 'r', encoding='iso-8859-1') as f:
1114 if not line.startswith('android.library.reference.'):
1116 path = line.split('=')[1].strip()
1117 relpath = os.path.join(root_dir, path)
1118 if not os.path.isdir(relpath):
1120 logging.debug("Found subproject at %s" % path)
1121 libraries.append(path)
1125 def ant_subprojects(root_dir):
1126 subprojects = get_library_references(root_dir)
1127 for subpath in subprojects:
1128 subrelpath = os.path.join(root_dir, subpath)
1129 for p in get_library_references(subrelpath):
1130 relp = os.path.normpath(os.path.join(subpath, p))
1131 if relp not in subprojects:
1132 subprojects.insert(0, relp)
1136 def remove_debuggable_flags(root_dir):
1137 # Remove forced debuggable flags
1138 logging.debug("Removing debuggable flags from %s" % root_dir)
1139 for root, dirs, files in os.walk(root_dir):
1140 if 'AndroidManifest.xml' in files:
1141 regsub_file(r'android:debuggable="[^"]*"',
1143 os.path.join(root, 'AndroidManifest.xml'))
1146 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1147 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1148 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1151 def app_matches_packagename(app, package):
1154 appid = app.UpdateCheckName or app.id
1155 if appid is None or appid == "Ignore":
1157 return appid == package
1160 def parse_androidmanifests(paths, app):
1162 Extract some information from the AndroidManifest.xml at the given path.
1163 Returns (version, vercode, package), any or all of which might be None.
1164 All values returned are strings.
1167 ignoreversions = app.UpdateCheckIgnore
1168 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1171 return (None, None, None)
1179 if not os.path.isfile(path):
1182 logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1187 if has_extension(path, 'gradle'):
1188 with open(path, 'r') as f:
1190 if gradle_comment.match(line):
1192 # Grab first occurence of each to avoid running into
1193 # alternative flavours and builds.
1195 matches = psearch_g(line)
1197 s = matches.group(2)
1198 if app_matches_packagename(app, s):
1201 matches = vnsearch_g(line)
1203 version = matches.group(2)
1205 matches = vcsearch_g(line)
1207 vercode = matches.group(1)
1210 xml = parse_xml(path)
1211 if "package" in xml.attrib:
1212 s = xml.attrib["package"]
1213 if app_matches_packagename(app, s):
1215 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1216 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1217 base_dir = os.path.dirname(path)
1218 version = retrieve_string_singleline(base_dir, version)
1219 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1220 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1221 if string_is_integer(a):
1224 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1226 # Remember package name, may be defined separately from version+vercode
1228 package = max_package
1230 logging.debug("..got package={0}, version={1}, vercode={2}"
1231 .format(package, version, vercode))
1233 # Always grab the package name and version name in case they are not
1234 # together with the highest version code
1235 if max_package is None and package is not None:
1236 max_package = package
1237 if max_version is None and version is not None:
1238 max_version = version
1240 if vercode is not None \
1241 and (max_vercode is None or vercode > max_vercode):
1242 if not ignoresearch or not ignoresearch(version):
1243 if version is not None:
1244 max_version = version
1245 if vercode is not None:
1246 max_vercode = vercode
1247 if package is not None:
1248 max_package = package
1250 max_version = "Ignore"
1252 if max_version is None:
1253 max_version = "Unknown"
1255 if max_package and not is_valid_package_name(max_package):
1256 raise FDroidException(_("Invalid package name {0}").format(max_package))
1258 return (max_version, max_vercode, max_package)
1261 def is_valid_package_name(name):
1262 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1265 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1266 raw=False, prepare=True, preponly=False, refresh=True,
1268 """Get the specified source library.
1270 Returns the path to it. Normally this is the path to be used when
1271 referencing it, which may be a subdirectory of the actual project. If
1272 you want the base directory of the project, pass 'basepath=True'.
1281 name, ref = spec.split('@')
1283 number, name = name.split(':', 1)
1285 name, subdir = name.split('/', 1)
1287 if name not in fdroidserver.metadata.srclibs:
1288 raise VCSException('srclib ' + name + ' not found.')
1290 srclib = fdroidserver.metadata.srclibs[name]
1292 sdir = os.path.join(srclib_dir, name)
1295 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1296 vcs.srclib = (name, number, sdir)
1298 vcs.gotorevision(ref, refresh)
1305 libdir = os.path.join(sdir, subdir)
1306 elif srclib["Subdir"]:
1307 for subdir in srclib["Subdir"]:
1308 libdir_candidate = os.path.join(sdir, subdir)
1309 if os.path.exists(libdir_candidate):
1310 libdir = libdir_candidate
1316 remove_signing_keys(sdir)
1317 remove_debuggable_flags(sdir)
1321 if srclib["Prepare"]:
1322 cmd = replace_config_vars(srclib["Prepare"], build)
1324 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1325 if p.returncode != 0:
1326 raise BuildException("Error running prepare command for srclib %s"
1332 return (name, number, libdir)
1335 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1338 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1339 """ Prepare the source code for a particular build
1341 :param vcs: the appropriate vcs object for the application
1342 :param app: the application details from the metadata
1343 :param build: the build details from the metadata
1344 :param build_dir: the path to the build directory, usually 'build/app.id'
1345 :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1346 :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1348 Returns the (root, srclibpaths) where:
1349 :param root: is the root directory, which may be the same as 'build_dir' or may
1350 be a subdirectory of it.
1351 :param srclibpaths: is information on the srclibs being used
1354 # Optionally, the actual app source can be in a subdirectory
1356 root_dir = os.path.join(build_dir, build.subdir)
1358 root_dir = build_dir
1360 # Get a working copy of the right revision
1361 logging.info("Getting source for revision " + build.commit)
1362 vcs.gotorevision(build.commit, refresh)
1364 # Initialise submodules if required
1365 if build.submodules:
1366 logging.info(_("Initialising submodules"))
1367 vcs.initsubmodules()
1369 # Check that a subdir (if we're using one) exists. This has to happen
1370 # after the checkout, since it might not exist elsewhere
1371 if not os.path.exists(root_dir):
1372 raise BuildException('Missing subdir ' + root_dir)
1374 # Run an init command if one is required
1376 cmd = replace_config_vars(build.init, build)
1377 logging.info("Running 'init' commands in %s" % root_dir)
1379 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1380 if p.returncode != 0:
1381 raise BuildException("Error running init command for %s:%s" %
1382 (app.id, build.versionName), p.output)
1384 # Apply patches if any
1386 logging.info("Applying patches")
1387 for patch in build.patch:
1388 patch = patch.strip()
1389 logging.info("Applying " + patch)
1390 patch_path = os.path.join('metadata', app.id, patch)
1391 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1392 if p.returncode != 0:
1393 raise BuildException("Failed to apply patch %s" % patch_path)
1395 # Get required source libraries
1398 logging.info("Collecting source libraries")
1399 for lib in build.srclibs:
1400 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1401 refresh=refresh, build=build))
1403 for name, number, libpath in srclibpaths:
1404 place_srclib(root_dir, int(number) if number else None, libpath)
1406 basesrclib = vcs.getsrclib()
1407 # If one was used for the main source, add that too.
1409 srclibpaths.append(basesrclib)
1411 # Update the local.properties file
1412 localprops = [os.path.join(build_dir, 'local.properties')]
1414 parts = build.subdir.split(os.sep)
1417 cur = os.path.join(cur, d)
1418 localprops += [os.path.join(cur, 'local.properties')]
1419 for path in localprops:
1421 if os.path.isfile(path):
1422 logging.info("Updating local.properties file at %s" % path)
1423 with open(path, 'r', encoding='iso-8859-1') as f:
1427 logging.info("Creating local.properties file at %s" % path)
1428 # Fix old-fashioned 'sdk-location' by copying
1429 # from sdk.dir, if necessary
1431 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1432 re.S | re.M).group(1)
1433 props += "sdk-location=%s\n" % sdkloc
1435 props += "sdk.dir=%s\n" % config['sdk_path']
1436 props += "sdk-location=%s\n" % config['sdk_path']
1437 ndk_path = build.ndk_path()
1438 # if for any reason the path isn't valid or the directory
1439 # doesn't exist, some versions of Gradle will error with a
1440 # cryptic message (even if the NDK is not even necessary).
1441 # https://gitlab.com/fdroid/fdroidserver/issues/171
1442 if ndk_path and os.path.exists(ndk_path):
1444 props += "ndk.dir=%s\n" % ndk_path
1445 props += "ndk-location=%s\n" % ndk_path
1446 # Add java.encoding if necessary
1448 props += "java.encoding=%s\n" % build.encoding
1449 with open(path, 'w', encoding='iso-8859-1') as f:
1453 if build.build_method() == 'gradle':
1454 flavours = build.gradle
1457 n = build.target.split('-')[1]
1458 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1459 r'compileSdkVersion %s' % n,
1460 os.path.join(root_dir, 'build.gradle'))
1462 # Remove forced debuggable flags
1463 remove_debuggable_flags(root_dir)
1465 # Insert version code and number into the manifest if necessary
1466 if build.forceversion:
1467 logging.info("Changing the version name")
1468 for path in manifest_paths(root_dir, flavours):
1469 if not os.path.isfile(path):
1471 if has_extension(path, 'xml'):
1472 regsub_file(r'android:versionName="[^"]*"',
1473 r'android:versionName="%s"' % build.versionName,
1475 elif has_extension(path, 'gradle'):
1476 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1477 r"""\1versionName '%s'""" % build.versionName,
1480 if build.forcevercode:
1481 logging.info("Changing the version code")
1482 for path in manifest_paths(root_dir, flavours):
1483 if not os.path.isfile(path):
1485 if has_extension(path, 'xml'):
1486 regsub_file(r'android:versionCode="[^"]*"',
1487 r'android:versionCode="%s"' % build.versionCode,
1489 elif has_extension(path, 'gradle'):
1490 regsub_file(r'versionCode[ =]+[0-9]+',
1491 r'versionCode %s' % build.versionCode,
1494 # Delete unwanted files
1496 logging.info(_("Removing specified files"))
1497 for part in getpaths(build_dir, build.rm):
1498 dest = os.path.join(build_dir, part)
1499 logging.info("Removing {0}".format(part))
1500 if os.path.lexists(dest):
1501 if os.path.islink(dest):
1502 FDroidPopen(['unlink', dest], output=False)
1504 FDroidPopen(['rm', '-rf', dest], output=False)
1506 logging.info("...but it didn't exist")
1508 remove_signing_keys(build_dir)
1510 # Add required external libraries
1512 logging.info("Collecting prebuilt libraries")
1513 libsdir = os.path.join(root_dir, 'libs')
1514 if not os.path.exists(libsdir):
1516 for lib in build.extlibs:
1518 logging.info("...installing extlib {0}".format(lib))
1519 libf = os.path.basename(lib)
1520 libsrc = os.path.join(extlib_dir, lib)
1521 if not os.path.exists(libsrc):
1522 raise BuildException("Missing extlib file {0}".format(libsrc))
1523 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1525 # Run a pre-build command if one is required
1527 logging.info("Running 'prebuild' commands in %s" % root_dir)
1529 cmd = replace_config_vars(build.prebuild, build)
1531 # Substitute source library paths into prebuild commands
1532 for name, number, libpath in srclibpaths:
1533 libpath = os.path.relpath(libpath, root_dir)
1534 cmd = cmd.replace('$$' + name + '$$', libpath)
1536 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1537 if p.returncode != 0:
1538 raise BuildException("Error running prebuild command for %s:%s" %
1539 (app.id, build.versionName), p.output)
1541 # Generate (or update) the ant build file, build.xml...
1542 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1543 parms = ['android', 'update', 'lib-project']
1544 lparms = ['android', 'update', 'project']
1547 parms += ['-t', build.target]
1548 lparms += ['-t', build.target]
1549 if build.androidupdate:
1550 update_dirs = build.androidupdate
1552 update_dirs = ant_subprojects(root_dir) + ['.']
1554 for d in update_dirs:
1555 subdir = os.path.join(root_dir, d)
1557 logging.debug("Updating main project")
1558 cmd = parms + ['-p', d]
1560 logging.debug("Updating subproject %s" % d)
1561 cmd = lparms + ['-p', d]
1562 p = SdkToolsPopen(cmd, cwd=root_dir)
1563 # Check to see whether an error was returned without a proper exit
1564 # code (this is the case for the 'no target set or target invalid'
1566 if p.returncode != 0 or p.output.startswith("Error: "):
1567 raise BuildException("Failed to update project at %s" % d, p.output)
1568 # Clean update dirs via ant
1570 logging.info("Cleaning subproject %s" % d)
1571 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1573 return (root_dir, srclibpaths)
1576 def getpaths_map(build_dir, globpaths):
1577 """Extend via globbing the paths from a field and return them as a map from original path to resulting paths"""
1581 full_path = os.path.join(build_dir, p)
1582 full_path = os.path.normpath(full_path)
1583 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1585 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1589 def getpaths(build_dir, globpaths):
1590 """Extend via globbing the paths from a field and return them as a set"""
1591 paths_map = getpaths_map(build_dir, globpaths)
1593 for k, v in paths_map.items():
1600 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1604 """permanent store of existing APKs with the date they were added
1606 This is currently the only way to permanently store the "updated"
1611 '''Load filename/date info about previously seen APKs
1613 Since the appid and date strings both will never have spaces,
1614 this is parsed as a list from the end to allow the filename to
1615 have any combo of spaces.
1618 self.path = os.path.join('stats', 'known_apks.txt')
1620 if os.path.isfile(self.path):
1621 with open(self.path, 'r', encoding='utf8') as f:
1623 t = line.rstrip().split(' ')
1625 self.apks[t[0]] = (t[1], None)
1628 date = datetime.strptime(t[-1], '%Y-%m-%d')
1629 filename = line[0:line.rfind(appid) - 1]
1630 self.apks[filename] = (appid, date)
1631 self.changed = False
1633 def writeifchanged(self):
1634 if not self.changed:
1637 if not os.path.exists('stats'):
1641 for apk, app in self.apks.items():
1643 line = apk + ' ' + appid
1645 line += ' ' + added.strftime('%Y-%m-%d')
1648 with open(self.path, 'w', encoding='utf8') as f:
1649 for line in sorted(lst, key=natural_key):
1650 f.write(line + '\n')
1652 def recordapk(self, apkName, app, default_date=None):
1654 Record an apk (if it's new, otherwise does nothing)
1655 Returns the date it was added as a datetime instance
1657 if apkName not in self.apks:
1658 if default_date is None:
1659 default_date = datetime.utcnow()
1660 self.apks[apkName] = (app, default_date)
1662 _, added = self.apks[apkName]
1665 def getapp(self, apkname):
1666 """Look up information - given the 'apkname', returns (app id, date added/None).
1668 Or returns None for an unknown apk.
1670 if apkname in self.apks:
1671 return self.apks[apkname]
1674 def getlatest(self, num):
1675 """Get the most recent 'num' apps added to the repo, as a list of package ids with the most recent first"""
1677 for apk, app in self.apks.items():
1681 if apps[appid] > added:
1685 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1686 lst = [app for app, _ignored in sortedapps]
1691 def get_file_extension(filename):
1692 """get the normalized file extension, can be blank string but never None"""
1693 if isinstance(filename, bytes):
1694 filename = filename.decode('utf-8')
1695 return os.path.splitext(filename)[1].lower()[1:]
1698 def get_apk_debuggable_aapt(apkfile):
1699 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1701 if p.returncode != 0:
1702 raise FDroidException(_("Failed to get APK manifest information"))
1703 for line in p.output.splitlines():
1704 if 'android:debuggable' in line and not line.endswith('0x0'):
1709 def get_apk_debuggable_androguard(apkfile):
1711 from androguard.core.bytecodes.apk import APK
1713 raise FDroidException("androguard library is not installed and aapt not present")
1715 apkobject = APK(apkfile)
1716 if apkobject.is_valid_APK():
1717 debuggable = apkobject.get_element("application", "debuggable")
1718 if debuggable is not None:
1719 return bool(strtobool(debuggable))
1723 def isApkAndDebuggable(apkfile):
1724 """Returns True if the given file is an APK and is debuggable
1726 :param apkfile: full path to the apk to check"""
1728 if get_file_extension(apkfile) != 'apk':
1731 if SdkToolsPopen(['aapt', 'version'], output=False):
1732 return get_apk_debuggable_aapt(apkfile)
1734 return get_apk_debuggable_androguard(apkfile)
1737 def get_apk_id_aapt(apkfile):
1738 """Extrat identification information from APK using aapt.
1740 :param apkfile: path to an APK file.
1741 :returns: triplet (appid, version code, version name)
1743 r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1744 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1745 for line in p.output.splitlines():
1748 return m.group('appid'), m.group('vercode'), m.group('vername')
1749 raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1750 .format(apkfilename=apkfile))
1755 self.returncode = None
1759 def SdkToolsPopen(commands, cwd=None, output=True):
1761 if cmd not in config:
1762 config[cmd] = find_sdk_tools_cmd(commands[0])
1763 abscmd = config[cmd]
1765 raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1767 test_aapt_version(config['aapt'])
1768 return FDroidPopen([abscmd] + commands[1:],
1769 cwd=cwd, output=output)
1772 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1774 Run a command and capture the possibly huge output as bytes.
1776 :param commands: command and argument list like in subprocess.Popen
1777 :param cwd: optionally specifies a working directory
1778 :param envs: a optional dictionary of environment variables and their values
1779 :returns: A PopenResult.
1784 set_FDroidPopen_env()
1786 process_env = env.copy()
1787 if envs is not None and len(envs) > 0:
1788 process_env.update(envs)
1791 cwd = os.path.normpath(cwd)
1792 logging.debug("Directory: %s" % cwd)
1793 logging.debug("> %s" % ' '.join(commands))
1795 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1796 result = PopenResult()
1799 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1800 stdout=subprocess.PIPE, stderr=stderr_param)
1801 except OSError as e:
1802 raise BuildException("OSError while trying to execute " +
1803 ' '.join(commands) + ': ' + str(e))
1805 if not stderr_to_stdout and options.verbose:
1806 stderr_queue = Queue()
1807 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1809 while not stderr_reader.eof():
1810 while not stderr_queue.empty():
1811 line = stderr_queue.get()
1812 sys.stderr.buffer.write(line)
1817 stdout_queue = Queue()
1818 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1821 # Check the queue for output (until there is no more to get)
1822 while not stdout_reader.eof():
1823 while not stdout_queue.empty():
1824 line = stdout_queue.get()
1825 if output and options.verbose:
1826 # Output directly to console
1827 sys.stderr.buffer.write(line)
1833 result.returncode = p.wait()
1834 result.output = buf.getvalue()
1836 # make sure all filestreams of the subprocess are closed
1837 for streamvar in ['stdin', 'stdout', 'stderr']:
1838 if hasattr(p, streamvar):
1839 stream = getattr(p, streamvar)
1845 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1847 Run a command and capture the possibly huge output as a str.
1849 :param commands: command and argument list like in subprocess.Popen
1850 :param cwd: optionally specifies a working directory
1851 :param envs: a optional dictionary of environment variables and their values
1852 :returns: A PopenResult.
1854 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1855 result.output = result.output.decode('utf-8', 'ignore')
1859 gradle_comment = re.compile(r'[ ]*//')
1860 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1861 gradle_line_matches = [
1862 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1863 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1864 re.compile(r'.*\.readLine\(.*'),
1868 def remove_signing_keys(build_dir):
1869 for root, dirs, files in os.walk(build_dir):
1870 if 'build.gradle' in files:
1871 path = os.path.join(root, 'build.gradle')
1873 with open(path, "r", encoding='utf8') as o:
1874 lines = o.readlines()
1880 with open(path, "w", encoding='utf8') as o:
1881 while i < len(lines):
1884 while line.endswith('\\\n'):
1885 line = line.rstrip('\\\n') + lines[i]
1888 if gradle_comment.match(line):
1893 opened += line.count('{')
1894 opened -= line.count('}')
1897 if gradle_signing_configs.match(line):
1902 if any(s.match(line) for s in gradle_line_matches):
1910 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1913 'project.properties',
1915 'default.properties',
1916 'ant.properties', ]:
1917 if propfile in files:
1918 path = os.path.join(root, propfile)
1920 with open(path, "r", encoding='iso-8859-1') as o:
1921 lines = o.readlines()
1925 with open(path, "w", encoding='iso-8859-1') as o:
1927 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1934 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1937 def set_FDroidPopen_env(build=None):
1939 set up the environment variables for the build environment
1941 There is only a weak standard, the variables used by gradle, so also set
1942 up the most commonly used environment variables for SDK and NDK. Also, if
1943 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1945 global env, orig_path
1949 orig_path = env['PATH']
1950 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1951 env[n] = config['sdk_path']
1952 for k, v in config['java_paths'].items():
1953 env['JAVA%s_HOME' % k] = v
1955 missinglocale = True
1956 for k, v in env.items():
1957 if k == 'LANG' and v != 'C':
1958 missinglocale = False
1960 missinglocale = False
1962 env['LANG'] = 'en_US.UTF-8'
1964 if build is not None:
1965 path = build.ndk_path()
1966 paths = orig_path.split(os.pathsep)
1967 if path not in paths:
1968 paths = [path] + paths
1969 env['PATH'] = os.pathsep.join(paths)
1970 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1971 env[n] = build.ndk_path()
1974 def replace_build_vars(cmd, build):
1975 cmd = cmd.replace('$$COMMIT$$', build.commit)
1976 cmd = cmd.replace('$$VERSION$$', build.versionName)
1977 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1981 def replace_config_vars(cmd, build):
1982 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1983 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1984 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1985 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1986 if build is not None:
1987 cmd = replace_build_vars(cmd, build)
1991 def place_srclib(root_dir, number, libpath):
1994 relpath = os.path.relpath(libpath, root_dir)
1995 proppath = os.path.join(root_dir, 'project.properties')
1998 if os.path.isfile(proppath):
1999 with open(proppath, "r", encoding='iso-8859-1') as o:
2000 lines = o.readlines()
2002 with open(proppath, "w", encoding='iso-8859-1') as o:
2005 if line.startswith('android.library.reference.%d=' % number):
2006 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2011 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2014 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2017 def metadata_get_sigdir(appid, vercode=None):
2018 """Get signature directory for app"""
2020 return os.path.join('metadata', appid, 'signatures', vercode)
2022 return os.path.join('metadata', appid, 'signatures')
2025 def apk_extract_signatures(apkpath, outdir, manifest=True):
2026 """Extracts a signature files from APK and puts them into target directory.
2028 :param apkpath: location of the apk
2029 :param outdir: folder where the extracted signature files will be stored
2030 :param manifest: (optionally) disable extracting manifest file
2032 with ZipFile(apkpath, 'r') as in_apk:
2033 for f in in_apk.infolist():
2034 if apk_sigfile.match(f.filename) or \
2035 (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2036 newpath = os.path.join(outdir, os.path.basename(f.filename))
2037 with open(newpath, 'wb') as out_file:
2038 out_file.write(in_apk.read(f.filename))
2041 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2042 """Verify that two apks are the same
2044 One of the inputs is signed, the other is unsigned. The signature metadata
2045 is transferred from the signed to the unsigned apk, and then jarsigner is
2046 used to verify that the signature from the signed apk is also varlid for
2047 the unsigned one. If the APK given as unsigned actually does have a
2048 signature, it will be stripped out and ignored.
2050 There are two SHA1 git commit IDs that fdroidserver includes in the builds
2051 it makes: fdroidserverid and buildserverid. Originally, these were inserted
2052 into AndroidManifest.xml, but that makes the build not reproducible. So
2053 instead they are included as separate files in the APK's META-INF/ folder.
2054 If those files exist in the signed APK, they will be part of the signature
2055 and need to also be included in the unsigned APK for it to validate.
2057 :param signed_apk: Path to a signed apk file
2058 :param unsigned_apk: Path to an unsigned apk file expected to match it
2059 :param tmp_dir: Path to directory for temporary files
2060 :returns: None if the verification is successful, otherwise a string
2061 describing what went wrong.
2064 signed = ZipFile(signed_apk, 'r')
2065 meta_inf_files = ['META-INF/MANIFEST.MF']
2066 for f in signed.namelist():
2067 if apk_sigfile.match(f) \
2068 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2069 meta_inf_files.append(f)
2070 if len(meta_inf_files) < 3:
2071 return "Signature files missing from {0}".format(signed_apk)
2073 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2074 unsigned = ZipFile(unsigned_apk, 'r')
2075 # only read the signature from the signed APK, everything else from unsigned
2076 with ZipFile(tmp_apk, 'w') as tmp:
2077 for filename in meta_inf_files:
2078 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2079 for info in unsigned.infolist():
2080 if info.filename in meta_inf_files:
2081 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2083 if info.filename in tmp.namelist():
2084 return "duplicate filename found: " + info.filename
2085 tmp.writestr(info, unsigned.read(info.filename))
2089 verified = verify_apk_signature(tmp_apk)
2092 logging.info("...NOT verified - {0}".format(tmp_apk))
2093 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2094 os.path.dirname(unsigned_apk))
2096 logging.info("...successfully verified")
2100 def verify_jar_signature(jar):
2101 """Verifies the signature of a given JAR file.
2103 jarsigner is very shitty: unsigned JARs pass as "verified"! So
2104 this has to turn on -strict then check for result 4, since this
2105 does not expect the signature to be from a CA-signed certificate.
2107 :raises: VerificationException() if the JAR's signature could not be verified
2111 if subprocess.call([config['jarsigner'], '-strict', '-verify', jar]) != 4:
2112 raise VerificationException(_("The repository's index could not be verified."))
2115 def verify_apk_signature(apk, min_sdk_version=None):
2116 """verify the signature on an APK
2118 Try to use apksigner whenever possible since jarsigner is very
2119 shitty: unsigned APKs pass as "verified"! Warning, this does
2120 not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2122 :returns: boolean whether the APK was verified
2124 if set_command_in_config('apksigner'):
2125 args = [config['apksigner'], 'verify']
2127 args += ['--min-sdk-version=' + min_sdk_version]
2128 return subprocess.call(args + [apk]) == 0
2130 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2132 verify_jar_signature(apk)
2139 def verify_old_apk_signature(apk):
2140 """verify the signature on an archived APK, supporting deprecated algorithms
2142 F-Droid aims to keep every single binary that it ever published. Therefore,
2143 it needs to be able to verify APK signatures that include deprecated/removed
2144 algorithms. For example, jarsigner treats an MD5 signature as unsigned.
2146 jarsigner passes unsigned APKs as "verified"! So this has to turn
2147 on -strict then check for result 4.
2149 :returns: boolean whether the APK was verified
2152 _java_security = os.path.join(os.getcwd(), '.java.security')
2153 with open(_java_security, 'w') as fp:
2154 fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2156 return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2157 '-strict', '-verify', apk]) == 4
2160 apk_badchars = re.compile('''[/ :;'"]''')
2163 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2166 Returns None if the apk content is the same (apart from the signing key),
2167 otherwise a string describing what's different, or what went wrong when
2168 trying to do the comparison.
2174 absapk1 = os.path.abspath(apk1)
2175 absapk2 = os.path.abspath(apk2)
2177 if set_command_in_config('diffoscope'):
2178 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2179 htmlfile = logfilename + '.diffoscope.html'
2180 textfile = logfilename + '.diffoscope.txt'
2181 if subprocess.call([config['diffoscope'],
2182 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2183 '--html', htmlfile, '--text', textfile,
2184 absapk1, absapk2]) != 0:
2185 return("Failed to unpack " + apk1)
2187 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2188 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2189 for d in [apk1dir, apk2dir]:
2190 if os.path.exists(d):
2193 os.mkdir(os.path.join(d, 'jar-xf'))
2195 if subprocess.call(['jar', 'xf',
2196 os.path.abspath(apk1)],
2197 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2198 return("Failed to unpack " + apk1)
2199 if subprocess.call(['jar', 'xf',
2200 os.path.abspath(apk2)],
2201 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2202 return("Failed to unpack " + apk2)
2204 if set_command_in_config('apktool'):
2205 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2207 return("Failed to unpack " + apk1)
2208 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2210 return("Failed to unpack " + apk2)
2212 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2213 lines = p.output.splitlines()
2214 if len(lines) != 1 or 'META-INF' not in lines[0]:
2215 if set_command_in_config('meld'):
2216 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2217 return("Unexpected diff output - " + p.output)
2219 # since everything verifies, delete the comparison to keep cruft down
2220 shutil.rmtree(apk1dir)
2221 shutil.rmtree(apk2dir)
2223 # If we get here, it seems like they're the same!
2227 def set_command_in_config(command):
2228 '''Try to find specified command in the path, if it hasn't been
2229 manually set in config.py. If found, it is added to the config
2230 dict. The return value says whether the command is available.
2233 if command in config:
2236 tmp = find_command(command)
2238 config[command] = tmp
2243 def find_command(command):
2244 '''find the full path of a command, or None if it can't be found in the PATH'''
2247 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2249 fpath, fname = os.path.split(command)
2254 for path in os.environ["PATH"].split(os.pathsep):
2255 path = path.strip('"')
2256 exe_file = os.path.join(path, command)
2257 if is_exe(exe_file):
2264 '''generate a random password for when generating keys'''
2265 h = hashlib.sha256()
2266 h.update(os.urandom(16)) # salt
2267 h.update(socket.getfqdn().encode('utf-8'))
2268 passwd = base64.b64encode(h.digest()).strip()
2269 return passwd.decode('utf-8')
2272 def genkeystore(localconfig):
2274 Generate a new key with password provided in :param localconfig and add it to new keystore
2275 :return: hexed public key, public key fingerprint
2277 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2278 keystoredir = os.path.dirname(localconfig['keystore'])
2279 if keystoredir is None or keystoredir == '':
2280 keystoredir = os.path.join(os.getcwd(), keystoredir)
2281 if not os.path.exists(keystoredir):
2282 os.makedirs(keystoredir, mode=0o700)
2285 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2286 'FDROID_KEY_PASS': localconfig['keypass'],
2288 p = FDroidPopen([config['keytool'], '-genkey',
2289 '-keystore', localconfig['keystore'],
2290 '-alias', localconfig['repo_keyalias'],
2291 '-keyalg', 'RSA', '-keysize', '4096',
2292 '-sigalg', 'SHA256withRSA',
2293 '-validity', '10000',
2294 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2295 '-keypass:env', 'FDROID_KEY_PASS',
2296 '-dname', localconfig['keydname']], envs=env_vars)
2297 if p.returncode != 0:
2298 raise BuildException("Failed to generate key", p.output)
2299 os.chmod(localconfig['keystore'], 0o0600)
2300 if not options.quiet:
2301 # now show the lovely key that was just generated
2302 p = FDroidPopen([config['keytool'], '-list', '-v',
2303 '-keystore', localconfig['keystore'],
2304 '-alias', localconfig['repo_keyalias'],
2305 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2306 logging.info(p.output.strip() + '\n\n')
2307 # get the public key
2308 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2309 '-keystore', localconfig['keystore'],
2310 '-alias', localconfig['repo_keyalias'],
2311 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2312 + config['smartcardoptions'],
2313 envs=env_vars, output=False, stderr_to_stdout=False)
2314 if p.returncode != 0 or len(p.output) < 20:
2315 raise BuildException("Failed to get public key", p.output)
2317 fingerprint = get_cert_fingerprint(pubkey)
2318 return hexlify(pubkey), fingerprint
2321 def get_cert_fingerprint(pubkey):
2323 Generate a certificate fingerprint the same way keytool does it
2324 (but with slightly different formatting)
2326 digest = hashlib.sha256(pubkey).digest()
2327 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2328 return " ".join(ret)
2331 def get_certificate(certificate_file):
2333 Extracts a certificate from the given file.
2334 :param certificate_file: file bytes (as string) representing the certificate
2335 :return: A binary representation of the certificate's public key, or None in case of error
2337 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2338 if content.getComponentByName('contentType') != rfc2315.signedData:
2340 content = decoder.decode(content.getComponentByName('content'),
2341 asn1Spec=rfc2315.SignedData())[0]
2343 certificates = content.getComponentByName('certificates')
2344 cert = certificates[0].getComponentByName('certificate')
2346 logging.error("Certificates not found.")
2348 return encoder.encode(cert)
2351 def write_to_config(thisconfig, key, value=None, config_file=None):
2352 '''write a key/value to the local config.py
2354 NOTE: only supports writing string variables.
2356 :param thisconfig: config dictionary
2357 :param key: variable name in config.py to be overwritten/added
2358 :param value: optional value to be written, instead of fetched
2359 from 'thisconfig' dictionary.
2362 origkey = key + '_orig'
2363 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2364 cfg = config_file if config_file else 'config.py'
2366 # load config file, create one if it doesn't exist
2367 if not os.path.exists(cfg):
2368 open(cfg, 'a').close()
2369 logging.info("Creating empty " + cfg)
2370 with open(cfg, 'r', encoding="utf-8") as f:
2371 lines = f.readlines()
2373 # make sure the file ends with a carraige return
2375 if not lines[-1].endswith('\n'):
2378 # regex for finding and replacing python string variable
2379 # definitions/initializations
2380 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2381 repl = key + ' = "' + value + '"'
2382 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2383 repl2 = key + " = '" + value + "'"
2385 # If we replaced this line once, we make sure won't be a
2386 # second instance of this line for this key in the document.
2389 with open(cfg, 'w', encoding="utf-8") as f:
2391 if pattern.match(line) or pattern2.match(line):
2393 line = pattern.sub(repl, line)
2394 line = pattern2.sub(repl2, line)
2405 def parse_xml(path):
2406 return XMLElementTree.parse(path).getroot()
2409 def string_is_integer(string):
2417 def get_per_app_repos():
2418 '''per-app repos are dirs named with the packageName of a single app'''
2420 # Android packageNames are Java packages, they may contain uppercase or
2421 # lowercase letters ('A' through 'Z'), numbers, and underscores
2422 # ('_'). However, individual package name parts may only start with
2423 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2424 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2427 for root, dirs, files in os.walk(os.getcwd()):
2429 print('checking', root, 'for', d)
2430 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2431 # standard parts of an fdroid repo, so never packageNames
2434 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2440 def is_repo_file(filename):
2441 '''Whether the file in a repo is a build product to be delivered to users'''
2442 if isinstance(filename, str):
2443 filename = filename.encode('utf-8', errors="surrogateescape")
2444 return os.path.isfile(filename) \
2445 and not filename.endswith(b'.asc') \
2446 and not filename.endswith(b'.sig') \
2447 and os.path.basename(filename) not in [
2449 b'index_unsigned.jar',