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 # Take the local repository to a clean version of the given revision, which
613 # is specificed in the VCS's native format. Beforehand, the repository can
614 # be dirty, or even non-existent. If the repository does already exist
615 # locally, it will be updated from the origin, but only once in the
616 # lifetime of the vcs object.
617 # None is acceptable for 'rev' if you know you are cloning a clean copy of
618 # the repo - otherwise it must specify a valid revision.
619 def gotorevision(self, rev, refresh=True):
621 if self.clone_failed:
622 raise VCSException(_("Downloading the repository already failed once, not trying again."))
624 # The .fdroidvcs-id file for a repo tells us what VCS type
625 # and remote that directory was created from, allowing us to drop it
626 # automatically if either of those things changes.
627 fdpath = os.path.join(self.local, '..',
628 '.fdroidvcs-' + os.path.basename(self.local))
629 fdpath = os.path.normpath(fdpath)
630 cdata = self.repotype() + ' ' + self.remote
633 if os.path.exists(self.local):
634 if os.path.exists(fdpath):
635 with open(fdpath, 'r') as f:
636 fsdata = f.read().strip()
641 logging.info("Repository details for %s changed - deleting" % (
645 logging.info("Repository details for %s missing - deleting" % (
648 shutil.rmtree(self.local)
652 self.refreshed = True
655 self.gotorevisionx(rev)
656 except FDroidException as e:
659 # If necessary, write the .fdroidvcs file.
660 if writeback and not self.clone_failed:
661 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
662 with open(fdpath, 'w+') as f:
668 # Derived classes need to implement this. It's called once basic checking
669 # has been performend.
670 def gotorevisionx(self, rev): # pylint: disable=unused-argument
671 raise VCSException("This VCS type doesn't define gotorevisionx")
673 # Initialise and update submodules
674 def initsubmodules(self):
675 raise VCSException('Submodules not supported for this vcs type')
677 # Get a list of all known tags
679 if not self._gettags:
680 raise VCSException('gettags not supported for this vcs type')
682 for tag in self._gettags():
683 if re.match('[-A-Za-z0-9_. /]+$', tag):
687 # Get a list of all the known tags, sorted from newest to oldest
688 def latesttags(self):
689 raise VCSException('latesttags not supported for this vcs type')
691 # Get current commit reference (hash, revision, etc)
693 raise VCSException('getref not supported for this vcs type')
695 # Returns the srclib (name, path) used in setting up the current
706 # If the local directory exists, but is somehow not a git repository, git
707 # will traverse up the directory tree until it finds one that is (i.e.
708 # fdroidserver) and then we'll proceed to destroy it! This is called as
711 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
712 result = p.output.rstrip()
713 if not result.endswith(self.local):
714 raise VCSException('Repository mismatch')
716 def gotorevisionx(self, rev):
717 if not os.path.exists(self.local):
719 p = FDroidPopen(['git', 'clone', self.remote, self.local])
720 if p.returncode != 0:
721 self.clone_failed = True
722 raise VCSException("Git clone failed", p.output)
726 # Discard any working tree changes
727 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
728 'git', 'reset', '--hard'], cwd=self.local, output=False)
729 if p.returncode != 0:
730 raise VCSException(_("Git reset failed"), p.output)
731 # Remove untracked files now, in case they're tracked in the target
732 # revision (it happens!)
733 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
734 'git', 'clean', '-dffx'], cwd=self.local, output=False)
735 if p.returncode != 0:
736 raise VCSException(_("Git clean failed"), p.output)
737 if not self.refreshed:
738 # Get latest commits and tags from remote
739 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
740 if p.returncode != 0:
741 raise VCSException(_("Git fetch failed"), p.output)
742 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
743 if p.returncode != 0:
744 raise VCSException(_("Git fetch failed"), p.output)
745 # Recreate origin/HEAD as git clone would do it, in case it disappeared
746 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
747 if p.returncode != 0:
748 lines = p.output.splitlines()
749 if 'Multiple remote HEAD branches' not in lines[0]:
750 raise VCSException(_("Git remote set-head failed"), p.output)
751 branch = lines[1].split(' ')[-1]
752 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
753 if p2.returncode != 0:
754 raise VCSException(_("Git remote set-head failed"), p.output + '\n' + p2.output)
755 self.refreshed = True
756 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
757 # a github repo. Most of the time this is the same as origin/master.
758 rev = rev or 'origin/HEAD'
759 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
760 if p.returncode != 0:
761 raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
762 # Get rid of any uncontrolled files left behind
763 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
764 if p.returncode != 0:
765 raise VCSException(_("Git clean failed"), p.output)
767 def initsubmodules(self):
769 submfile = os.path.join(self.local, '.gitmodules')
770 if not os.path.isfile(submfile):
771 raise VCSException(_("No git submodules available"))
773 # fix submodules not accessible without an account and public key auth
774 with open(submfile, 'r') as f:
775 lines = f.readlines()
776 with open(submfile, 'w') as f:
778 if 'git@github.com' in line:
779 line = line.replace('git@github.com:', 'https://github.com/')
780 if 'git@gitlab.com' in line:
781 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
784 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
785 if p.returncode != 0:
786 raise VCSException(_("Git submodule sync failed"), p.output)
787 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
788 if p.returncode != 0:
789 raise VCSException(_("Git submodule update failed"), p.output)
793 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
794 return p.output.splitlines()
796 tag_format = re.compile(r'tag: ([^),]*)')
798 def latesttags(self):
800 p = FDroidPopen(['git', 'log', '--tags',
801 '--simplify-by-decoration', '--pretty=format:%d'],
802 cwd=self.local, output=False)
804 for line in p.output.splitlines():
805 for tag in self.tag_format.findall(line):
810 class vcs_gitsvn(vcs):
815 # If the local directory exists, but is somehow not a git repository, git
816 # will traverse up the directory tree until it finds one that is (i.e.
817 # fdroidserver) and then we'll proceed to destory it! This is called as
820 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
821 result = p.output.rstrip()
822 if not result.endswith(self.local):
823 raise VCSException('Repository mismatch')
825 def gotorevisionx(self, rev):
826 if not os.path.exists(self.local):
828 gitsvn_args = ['git', 'svn', 'clone']
829 if ';' in self.remote:
830 remote_split = self.remote.split(';')
831 for i in remote_split[1:]:
832 if i.startswith('trunk='):
833 gitsvn_args.extend(['-T', i[6:]])
834 elif i.startswith('tags='):
835 gitsvn_args.extend(['-t', i[5:]])
836 elif i.startswith('branches='):
837 gitsvn_args.extend(['-b', i[9:]])
838 gitsvn_args.extend([remote_split[0], self.local])
839 p = FDroidPopen(gitsvn_args, output=False)
840 if p.returncode != 0:
841 self.clone_failed = True
842 raise VCSException("Git svn clone failed", p.output)
844 gitsvn_args.extend([self.remote, self.local])
845 p = FDroidPopen(gitsvn_args, output=False)
846 if p.returncode != 0:
847 self.clone_failed = True
848 raise VCSException("Git svn clone failed", p.output)
852 # Discard any working tree changes
853 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
854 if p.returncode != 0:
855 raise VCSException("Git reset failed", p.output)
856 # Remove untracked files now, in case they're tracked in the target
857 # revision (it happens!)
858 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
859 if p.returncode != 0:
860 raise VCSException("Git clean failed", p.output)
861 if not self.refreshed:
862 # Get new commits, branches and tags from repo
863 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
864 if p.returncode != 0:
865 raise VCSException("Git svn fetch failed")
866 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
867 if p.returncode != 0:
868 raise VCSException("Git svn rebase failed", p.output)
869 self.refreshed = True
871 rev = rev or 'master'
873 nospaces_rev = rev.replace(' ', '%20')
874 # Try finding a svn tag
875 for treeish in ['origin/', '']:
876 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
877 if p.returncode == 0:
879 if p.returncode != 0:
880 # No tag found, normal svn rev translation
881 # Translate svn rev into git format
882 rev_split = rev.split('/')
885 for treeish in ['origin/', '']:
886 if len(rev_split) > 1:
887 treeish += rev_split[0]
888 svn_rev = rev_split[1]
891 # if no branch is specified, then assume trunk (i.e. 'master' branch):
895 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
897 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
898 git_rev = p.output.rstrip()
900 if p.returncode == 0 and git_rev:
903 if p.returncode != 0 or not git_rev:
904 # Try a plain git checkout as a last resort
905 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
906 if p.returncode != 0:
907 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
909 # Check out the git rev equivalent to the svn rev
910 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
911 if p.returncode != 0:
912 raise VCSException(_("Git checkout of '%s' failed") % rev, p.output)
914 # Get rid of any uncontrolled files left behind
915 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
916 if p.returncode != 0:
917 raise VCSException(_("Git clean failed"), p.output)
921 for treeish in ['origin/', '']:
922 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
928 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
929 if p.returncode != 0:
931 return p.output.strip()
939 def gotorevisionx(self, rev):
940 if not os.path.exists(self.local):
941 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
942 if p.returncode != 0:
943 self.clone_failed = True
944 raise VCSException("Hg clone failed", p.output)
946 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
947 if p.returncode != 0:
948 raise VCSException("Hg status failed", p.output)
949 for line in p.output.splitlines():
950 if not line.startswith('? '):
951 raise VCSException("Unexpected output from hg status -uS: " + line)
952 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
953 if not self.refreshed:
954 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
955 if p.returncode != 0:
956 raise VCSException("Hg pull failed", p.output)
957 self.refreshed = True
959 rev = rev or 'default'
962 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
963 if p.returncode != 0:
964 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
965 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
966 # Also delete untracked files, we have to enable purge extension for that:
967 if "'purge' is provided by the following extension" in p.output:
968 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
969 myfile.write("\n[extensions]\nhgext.purge=\n")
970 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
971 if p.returncode != 0:
972 raise VCSException("HG purge failed", p.output)
973 elif p.returncode != 0:
974 raise VCSException("HG purge failed", p.output)
977 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
978 return p.output.splitlines()[1:]
986 def gotorevisionx(self, rev):
987 if not os.path.exists(self.local):
988 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
989 if p.returncode != 0:
990 self.clone_failed = True
991 raise VCSException("Bzr branch failed", p.output)
993 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
994 if p.returncode != 0:
995 raise VCSException("Bzr revert failed", p.output)
996 if not self.refreshed:
997 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
998 if p.returncode != 0:
999 raise VCSException("Bzr update failed", p.output)
1000 self.refreshed = True
1002 revargs = list(['-r', rev] if rev else [])
1003 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1004 if p.returncode != 0:
1005 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1008 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1009 return [tag.split(' ')[0].strip() for tag in
1010 p.output.splitlines()]
1013 def unescape_string(string):
1016 if string[0] == '"' and string[-1] == '"':
1019 return string.replace("\\'", "'")
1022 def retrieve_string(app_dir, string, xmlfiles=None):
1024 if not string.startswith('@string/'):
1025 return unescape_string(string)
1027 if xmlfiles is None:
1030 os.path.join(app_dir, 'res'),
1031 os.path.join(app_dir, 'src', 'main', 'res'),
1033 for root, dirs, files in os.walk(res_dir):
1034 if os.path.basename(root) == 'values':
1035 xmlfiles += [os.path.join(root, x) for x in files if x.endswith('.xml')]
1037 name = string[len('@string/'):]
1039 def element_content(element):
1040 if element.text is None:
1042 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1043 return s.decode('utf-8').strip()
1045 for path in xmlfiles:
1046 if not os.path.isfile(path):
1048 xml = parse_xml(path)
1049 element = xml.find('string[@name="' + name + '"]')
1050 if element is not None:
1051 content = element_content(element)
1052 return retrieve_string(app_dir, content, xmlfiles)
1057 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1058 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1061 def manifest_paths(app_dir, flavours):
1062 '''Return list of existing files that will be used to find the highest vercode'''
1064 possible_manifests = \
1065 [os.path.join(app_dir, 'AndroidManifest.xml'),
1066 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1067 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1068 os.path.join(app_dir, 'build.gradle')]
1070 for flavour in flavours:
1071 if flavour == 'yes':
1073 possible_manifests.append(
1074 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1076 return [path for path in possible_manifests if os.path.isfile(path)]
1079 def fetch_real_name(app_dir, flavours):
1080 '''Retrieve the package name. Returns the name, or None if not found.'''
1081 for path in manifest_paths(app_dir, flavours):
1082 if not has_extension(path, 'xml') or not os.path.isfile(path):
1084 logging.debug("fetch_real_name: Checking manifest at " + path)
1085 xml = parse_xml(path)
1086 app = xml.find('application')
1089 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1091 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1092 result = retrieve_string_singleline(app_dir, label)
1094 result = result.strip()
1099 def get_library_references(root_dir):
1101 proppath = os.path.join(root_dir, 'project.properties')
1102 if not os.path.isfile(proppath):
1104 with open(proppath, 'r', encoding='iso-8859-1') as f:
1106 if not line.startswith('android.library.reference.'):
1108 path = line.split('=')[1].strip()
1109 relpath = os.path.join(root_dir, path)
1110 if not os.path.isdir(relpath):
1112 logging.debug("Found subproject at %s" % path)
1113 libraries.append(path)
1117 def ant_subprojects(root_dir):
1118 subprojects = get_library_references(root_dir)
1119 for subpath in subprojects:
1120 subrelpath = os.path.join(root_dir, subpath)
1121 for p in get_library_references(subrelpath):
1122 relp = os.path.normpath(os.path.join(subpath, p))
1123 if relp not in subprojects:
1124 subprojects.insert(0, relp)
1128 def remove_debuggable_flags(root_dir):
1129 # Remove forced debuggable flags
1130 logging.debug("Removing debuggable flags from %s" % root_dir)
1131 for root, dirs, files in os.walk(root_dir):
1132 if 'AndroidManifest.xml' in files:
1133 regsub_file(r'android:debuggable="[^"]*"',
1135 os.path.join(root, 'AndroidManifest.xml'))
1138 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1139 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1140 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1143 def app_matches_packagename(app, package):
1146 appid = app.UpdateCheckName or app.id
1147 if appid is None or appid == "Ignore":
1149 return appid == package
1152 def parse_androidmanifests(paths, app):
1154 Extract some information from the AndroidManifest.xml at the given path.
1155 Returns (version, vercode, package), any or all of which might be None.
1156 All values returned are strings.
1159 ignoreversions = app.UpdateCheckIgnore
1160 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1163 return (None, None, None)
1171 if not os.path.isfile(path):
1174 logging.debug(_("Parsing manifest at '{path}'").format(path=path))
1179 if has_extension(path, 'gradle'):
1180 with open(path, 'r') as f:
1182 if gradle_comment.match(line):
1184 # Grab first occurence of each to avoid running into
1185 # alternative flavours and builds.
1187 matches = psearch_g(line)
1189 s = matches.group(2)
1190 if app_matches_packagename(app, s):
1193 matches = vnsearch_g(line)
1195 version = matches.group(2)
1197 matches = vcsearch_g(line)
1199 vercode = matches.group(1)
1202 xml = parse_xml(path)
1203 if "package" in xml.attrib:
1204 s = xml.attrib["package"]
1205 if app_matches_packagename(app, s):
1207 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1208 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1209 base_dir = os.path.dirname(path)
1210 version = retrieve_string_singleline(base_dir, version)
1211 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1212 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1213 if string_is_integer(a):
1216 logging.warning(_("Problem with xml at '{path}'").format(path=path))
1218 # Remember package name, may be defined separately from version+vercode
1220 package = max_package
1222 logging.debug("..got package={0}, version={1}, vercode={2}"
1223 .format(package, version, vercode))
1225 # Always grab the package name and version name in case they are not
1226 # together with the highest version code
1227 if max_package is None and package is not None:
1228 max_package = package
1229 if max_version is None and version is not None:
1230 max_version = version
1232 if vercode is not None \
1233 and (max_vercode is None or vercode > max_vercode):
1234 if not ignoresearch or not ignoresearch(version):
1235 if version is not None:
1236 max_version = version
1237 if vercode is not None:
1238 max_vercode = vercode
1239 if package is not None:
1240 max_package = package
1242 max_version = "Ignore"
1244 if max_version is None:
1245 max_version = "Unknown"
1247 if max_package and not is_valid_package_name(max_package):
1248 raise FDroidException(_("Invalid package name {0}").format(max_package))
1250 return (max_version, max_vercode, max_package)
1253 def is_valid_package_name(name):
1254 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1257 # Get the specified source library.
1258 # Returns the path to it. Normally this is the path to be used when referencing
1259 # it, which may be a subdirectory of the actual project. If you want the base
1260 # directory of the project, pass 'basepath=True'.
1261 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1262 raw=False, prepare=True, preponly=False, refresh=True,
1271 name, ref = spec.split('@')
1273 number, name = name.split(':', 1)
1275 name, subdir = name.split('/', 1)
1277 if name not in fdroidserver.metadata.srclibs:
1278 raise VCSException('srclib ' + name + ' not found.')
1280 srclib = fdroidserver.metadata.srclibs[name]
1282 sdir = os.path.join(srclib_dir, name)
1285 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1286 vcs.srclib = (name, number, sdir)
1288 vcs.gotorevision(ref, refresh)
1295 libdir = os.path.join(sdir, subdir)
1296 elif srclib["Subdir"]:
1297 for subdir in srclib["Subdir"]:
1298 libdir_candidate = os.path.join(sdir, subdir)
1299 if os.path.exists(libdir_candidate):
1300 libdir = libdir_candidate
1306 remove_signing_keys(sdir)
1307 remove_debuggable_flags(sdir)
1311 if srclib["Prepare"]:
1312 cmd = replace_config_vars(srclib["Prepare"], build)
1314 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1315 if p.returncode != 0:
1316 raise BuildException("Error running prepare command for srclib %s"
1322 return (name, number, libdir)
1325 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1328 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1329 """ Prepare the source code for a particular build
1331 :param vcs: the appropriate vcs object for the application
1332 :param app: the application details from the metadata
1333 :param build: the build details from the metadata
1334 :param build_dir: the path to the build directory, usually 'build/app.id'
1335 :param srclib_dir: the path to the source libraries directory, usually 'build/srclib'
1336 :param extlib_dir: the path to the external libraries directory, usually 'build/extlib'
1338 Returns the (root, srclibpaths) where:
1339 :param root: is the root directory, which may be the same as 'build_dir' or may
1340 be a subdirectory of it.
1341 :param srclibpaths: is information on the srclibs being used
1344 # Optionally, the actual app source can be in a subdirectory
1346 root_dir = os.path.join(build_dir, build.subdir)
1348 root_dir = build_dir
1350 # Get a working copy of the right revision
1351 logging.info("Getting source for revision " + build.commit)
1352 vcs.gotorevision(build.commit, refresh)
1354 # Initialise submodules if required
1355 if build.submodules:
1356 logging.info(_("Initialising submodules"))
1357 vcs.initsubmodules()
1359 # Check that a subdir (if we're using one) exists. This has to happen
1360 # after the checkout, since it might not exist elsewhere
1361 if not os.path.exists(root_dir):
1362 raise BuildException('Missing subdir ' + root_dir)
1364 # Run an init command if one is required
1366 cmd = replace_config_vars(build.init, build)
1367 logging.info("Running 'init' commands in %s" % root_dir)
1369 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1370 if p.returncode != 0:
1371 raise BuildException("Error running init command for %s:%s" %
1372 (app.id, build.versionName), p.output)
1374 # Apply patches if any
1376 logging.info("Applying patches")
1377 for patch in build.patch:
1378 patch = patch.strip()
1379 logging.info("Applying " + patch)
1380 patch_path = os.path.join('metadata', app.id, patch)
1381 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1382 if p.returncode != 0:
1383 raise BuildException("Failed to apply patch %s" % patch_path)
1385 # Get required source libraries
1388 logging.info("Collecting source libraries")
1389 for lib in build.srclibs:
1390 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1391 refresh=refresh, build=build))
1393 for name, number, libpath in srclibpaths:
1394 place_srclib(root_dir, int(number) if number else None, libpath)
1396 basesrclib = vcs.getsrclib()
1397 # If one was used for the main source, add that too.
1399 srclibpaths.append(basesrclib)
1401 # Update the local.properties file
1402 localprops = [os.path.join(build_dir, 'local.properties')]
1404 parts = build.subdir.split(os.sep)
1407 cur = os.path.join(cur, d)
1408 localprops += [os.path.join(cur, 'local.properties')]
1409 for path in localprops:
1411 if os.path.isfile(path):
1412 logging.info("Updating local.properties file at %s" % path)
1413 with open(path, 'r', encoding='iso-8859-1') as f:
1417 logging.info("Creating local.properties file at %s" % path)
1418 # Fix old-fashioned 'sdk-location' by copying
1419 # from sdk.dir, if necessary
1421 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1422 re.S | re.M).group(1)
1423 props += "sdk-location=%s\n" % sdkloc
1425 props += "sdk.dir=%s\n" % config['sdk_path']
1426 props += "sdk-location=%s\n" % config['sdk_path']
1427 ndk_path = build.ndk_path()
1428 # if for any reason the path isn't valid or the directory
1429 # doesn't exist, some versions of Gradle will error with a
1430 # cryptic message (even if the NDK is not even necessary).
1431 # https://gitlab.com/fdroid/fdroidserver/issues/171
1432 if ndk_path and os.path.exists(ndk_path):
1434 props += "ndk.dir=%s\n" % ndk_path
1435 props += "ndk-location=%s\n" % ndk_path
1436 # Add java.encoding if necessary
1438 props += "java.encoding=%s\n" % build.encoding
1439 with open(path, 'w', encoding='iso-8859-1') as f:
1443 if build.build_method() == 'gradle':
1444 flavours = build.gradle
1447 n = build.target.split('-')[1]
1448 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1449 r'compileSdkVersion %s' % n,
1450 os.path.join(root_dir, 'build.gradle'))
1452 # Remove forced debuggable flags
1453 remove_debuggable_flags(root_dir)
1455 # Insert version code and number into the manifest if necessary
1456 if build.forceversion:
1457 logging.info("Changing the version name")
1458 for path in manifest_paths(root_dir, flavours):
1459 if not os.path.isfile(path):
1461 if has_extension(path, 'xml'):
1462 regsub_file(r'android:versionName="[^"]*"',
1463 r'android:versionName="%s"' % build.versionName,
1465 elif has_extension(path, 'gradle'):
1466 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1467 r"""\1versionName '%s'""" % build.versionName,
1470 if build.forcevercode:
1471 logging.info("Changing the version code")
1472 for path in manifest_paths(root_dir, flavours):
1473 if not os.path.isfile(path):
1475 if has_extension(path, 'xml'):
1476 regsub_file(r'android:versionCode="[^"]*"',
1477 r'android:versionCode="%s"' % build.versionCode,
1479 elif has_extension(path, 'gradle'):
1480 regsub_file(r'versionCode[ =]+[0-9]+',
1481 r'versionCode %s' % build.versionCode,
1484 # Delete unwanted files
1486 logging.info(_("Removing specified files"))
1487 for part in getpaths(build_dir, build.rm):
1488 dest = os.path.join(build_dir, part)
1489 logging.info("Removing {0}".format(part))
1490 if os.path.lexists(dest):
1491 if os.path.islink(dest):
1492 FDroidPopen(['unlink', dest], output=False)
1494 FDroidPopen(['rm', '-rf', dest], output=False)
1496 logging.info("...but it didn't exist")
1498 remove_signing_keys(build_dir)
1500 # Add required external libraries
1502 logging.info("Collecting prebuilt libraries")
1503 libsdir = os.path.join(root_dir, 'libs')
1504 if not os.path.exists(libsdir):
1506 for lib in build.extlibs:
1508 logging.info("...installing extlib {0}".format(lib))
1509 libf = os.path.basename(lib)
1510 libsrc = os.path.join(extlib_dir, lib)
1511 if not os.path.exists(libsrc):
1512 raise BuildException("Missing extlib file {0}".format(libsrc))
1513 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1515 # Run a pre-build command if one is required
1517 logging.info("Running 'prebuild' commands in %s" % root_dir)
1519 cmd = replace_config_vars(build.prebuild, build)
1521 # Substitute source library paths into prebuild commands
1522 for name, number, libpath in srclibpaths:
1523 libpath = os.path.relpath(libpath, root_dir)
1524 cmd = cmd.replace('$$' + name + '$$', libpath)
1526 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1527 if p.returncode != 0:
1528 raise BuildException("Error running prebuild command for %s:%s" %
1529 (app.id, build.versionName), p.output)
1531 # Generate (or update) the ant build file, build.xml...
1532 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1533 parms = ['android', 'update', 'lib-project']
1534 lparms = ['android', 'update', 'project']
1537 parms += ['-t', build.target]
1538 lparms += ['-t', build.target]
1539 if build.androidupdate:
1540 update_dirs = build.androidupdate
1542 update_dirs = ant_subprojects(root_dir) + ['.']
1544 for d in update_dirs:
1545 subdir = os.path.join(root_dir, d)
1547 logging.debug("Updating main project")
1548 cmd = parms + ['-p', d]
1550 logging.debug("Updating subproject %s" % d)
1551 cmd = lparms + ['-p', d]
1552 p = SdkToolsPopen(cmd, cwd=root_dir)
1553 # Check to see whether an error was returned without a proper exit
1554 # code (this is the case for the 'no target set or target invalid'
1556 if p.returncode != 0 or p.output.startswith("Error: "):
1557 raise BuildException("Failed to update project at %s" % d, p.output)
1558 # Clean update dirs via ant
1560 logging.info("Cleaning subproject %s" % d)
1561 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1563 return (root_dir, srclibpaths)
1566 # Extend via globbing the paths from a field and return them as a map from
1567 # original path to resulting paths
1568 def getpaths_map(build_dir, globpaths):
1572 full_path = os.path.join(build_dir, p)
1573 full_path = os.path.normpath(full_path)
1574 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1576 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1580 # Extend via globbing the paths from a field and return them as a set
1581 def getpaths(build_dir, globpaths):
1582 paths_map = getpaths_map(build_dir, globpaths)
1584 for k, v in paths_map.items():
1591 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1595 """permanent store of existing APKs with the date they were added
1597 This is currently the only way to permanently store the "updated"
1602 '''Load filename/date info about previously seen APKs
1604 Since the appid and date strings both will never have spaces,
1605 this is parsed as a list from the end to allow the filename to
1606 have any combo of spaces.
1609 self.path = os.path.join('stats', 'known_apks.txt')
1611 if os.path.isfile(self.path):
1612 with open(self.path, 'r', encoding='utf8') as f:
1614 t = line.rstrip().split(' ')
1616 self.apks[t[0]] = (t[1], None)
1619 date = datetime.strptime(t[-1], '%Y-%m-%d')
1620 filename = line[0:line.rfind(appid) - 1]
1621 self.apks[filename] = (appid, date)
1622 self.changed = False
1624 def writeifchanged(self):
1625 if not self.changed:
1628 if not os.path.exists('stats'):
1632 for apk, app in self.apks.items():
1634 line = apk + ' ' + appid
1636 line += ' ' + added.strftime('%Y-%m-%d')
1639 with open(self.path, 'w', encoding='utf8') as f:
1640 for line in sorted(lst, key=natural_key):
1641 f.write(line + '\n')
1643 def recordapk(self, apkName, app, default_date=None):
1645 Record an apk (if it's new, otherwise does nothing)
1646 Returns the date it was added as a datetime instance
1648 if apkName not in self.apks:
1649 if default_date is None:
1650 default_date = datetime.utcnow()
1651 self.apks[apkName] = (app, default_date)
1653 _, added = self.apks[apkName]
1656 # Look up information - given the 'apkname', returns (app id, date added/None).
1657 # Or returns None for an unknown apk.
1658 def getapp(self, apkname):
1659 if apkname in self.apks:
1660 return self.apks[apkname]
1663 # Get the most recent 'num' apps added to the repo, as a list of package ids
1664 # with the most recent first.
1665 def getlatest(self, num):
1667 for apk, app in self.apks.items():
1671 if apps[appid] > added:
1675 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1676 lst = [app for app, _ignored in sortedapps]
1681 def get_file_extension(filename):
1682 """get the normalized file extension, can be blank string but never None"""
1683 if isinstance(filename, bytes):
1684 filename = filename.decode('utf-8')
1685 return os.path.splitext(filename)[1].lower()[1:]
1688 def get_apk_debuggable_aapt(apkfile):
1689 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1691 if p.returncode != 0:
1692 raise FDroidException(_("Failed to get APK manifest information"))
1693 for line in p.output.splitlines():
1694 if 'android:debuggable' in line and not line.endswith('0x0'):
1699 def get_apk_debuggable_androguard(apkfile):
1701 from androguard.core.bytecodes.apk import APK
1703 raise FDroidException("androguard library is not installed and aapt not present")
1705 apkobject = APK(apkfile)
1706 if apkobject.is_valid_APK():
1707 debuggable = apkobject.get_element("application", "debuggable")
1708 if debuggable is not None:
1709 return bool(strtobool(debuggable))
1713 def isApkAndDebuggable(apkfile):
1714 """Returns True if the given file is an APK and is debuggable
1716 :param apkfile: full path to the apk to check"""
1718 if get_file_extension(apkfile) != 'apk':
1721 if SdkToolsPopen(['aapt', 'version'], output=False):
1722 return get_apk_debuggable_aapt(apkfile)
1724 return get_apk_debuggable_androguard(apkfile)
1727 def get_apk_id_aapt(apkfile):
1728 """Extrat identification information from APK using aapt.
1730 :param apkfile: path to an APK file.
1731 :returns: triplet (appid, version code, version name)
1733 r = re.compile("package: name='(?P<appid>.*)' versionCode='(?P<vercode>.*)' versionName='(?P<vername>.*)' platformBuildVersionName='.*'")
1734 p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
1735 for line in p.output.splitlines():
1738 return m.group('appid'), m.group('vercode'), m.group('vername')
1739 raise FDroidException(_("Reading packageName/versionCode/versionName failed, APK invalid: '{apkfilename}'")
1740 .format(apkfilename=apkfile))
1745 self.returncode = None
1749 def SdkToolsPopen(commands, cwd=None, output=True):
1751 if cmd not in config:
1752 config[cmd] = find_sdk_tools_cmd(commands[0])
1753 abscmd = config[cmd]
1755 raise FDroidException(_("Could not find '{command}' on your system").format(command=cmd))
1757 test_aapt_version(config['aapt'])
1758 return FDroidPopen([abscmd] + commands[1:],
1759 cwd=cwd, output=output)
1762 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1764 Run a command and capture the possibly huge output as bytes.
1766 :param commands: command and argument list like in subprocess.Popen
1767 :param cwd: optionally specifies a working directory
1768 :param envs: a optional dictionary of environment variables and their values
1769 :returns: A PopenResult.
1774 set_FDroidPopen_env()
1776 process_env = env.copy()
1777 if envs is not None and len(envs) > 0:
1778 process_env.update(envs)
1781 cwd = os.path.normpath(cwd)
1782 logging.debug("Directory: %s" % cwd)
1783 logging.debug("> %s" % ' '.join(commands))
1785 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1786 result = PopenResult()
1789 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1790 stdout=subprocess.PIPE, stderr=stderr_param)
1791 except OSError as e:
1792 raise BuildException("OSError while trying to execute " +
1793 ' '.join(commands) + ': ' + str(e))
1795 if not stderr_to_stdout and options.verbose:
1796 stderr_queue = Queue()
1797 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1799 while not stderr_reader.eof():
1800 while not stderr_queue.empty():
1801 line = stderr_queue.get()
1802 sys.stderr.buffer.write(line)
1807 stdout_queue = Queue()
1808 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1811 # Check the queue for output (until there is no more to get)
1812 while not stdout_reader.eof():
1813 while not stdout_queue.empty():
1814 line = stdout_queue.get()
1815 if output and options.verbose:
1816 # Output directly to console
1817 sys.stderr.buffer.write(line)
1823 result.returncode = p.wait()
1824 result.output = buf.getvalue()
1826 # make sure all filestreams of the subprocess are closed
1827 for streamvar in ['stdin', 'stdout', 'stderr']:
1828 if hasattr(p, streamvar):
1829 stream = getattr(p, streamvar)
1835 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1837 Run a command and capture the possibly huge output as a str.
1839 :param commands: command and argument list like in subprocess.Popen
1840 :param cwd: optionally specifies a working directory
1841 :param envs: a optional dictionary of environment variables and their values
1842 :returns: A PopenResult.
1844 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1845 result.output = result.output.decode('utf-8', 'ignore')
1849 gradle_comment = re.compile(r'[ ]*//')
1850 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1851 gradle_line_matches = [
1852 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1853 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1854 re.compile(r'.*\.readLine\(.*'),
1858 def remove_signing_keys(build_dir):
1859 for root, dirs, files in os.walk(build_dir):
1860 if 'build.gradle' in files:
1861 path = os.path.join(root, 'build.gradle')
1863 with open(path, "r", encoding='utf8') as o:
1864 lines = o.readlines()
1870 with open(path, "w", encoding='utf8') as o:
1871 while i < len(lines):
1874 while line.endswith('\\\n'):
1875 line = line.rstrip('\\\n') + lines[i]
1878 if gradle_comment.match(line):
1883 opened += line.count('{')
1884 opened -= line.count('}')
1887 if gradle_signing_configs.match(line):
1892 if any(s.match(line) for s in gradle_line_matches):
1900 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1903 'project.properties',
1905 'default.properties',
1906 'ant.properties', ]:
1907 if propfile in files:
1908 path = os.path.join(root, propfile)
1910 with open(path, "r", encoding='iso-8859-1') as o:
1911 lines = o.readlines()
1915 with open(path, "w", encoding='iso-8859-1') as o:
1917 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1924 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1927 def set_FDroidPopen_env(build=None):
1929 set up the environment variables for the build environment
1931 There is only a weak standard, the variables used by gradle, so also set
1932 up the most commonly used environment variables for SDK and NDK. Also, if
1933 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1935 global env, orig_path
1939 orig_path = env['PATH']
1940 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1941 env[n] = config['sdk_path']
1942 for k, v in config['java_paths'].items():
1943 env['JAVA%s_HOME' % k] = v
1945 missinglocale = True
1946 for k, v in env.items():
1947 if k == 'LANG' and v != 'C':
1948 missinglocale = False
1950 missinglocale = False
1952 env['LANG'] = 'en_US.UTF-8'
1954 if build is not None:
1955 path = build.ndk_path()
1956 paths = orig_path.split(os.pathsep)
1957 if path not in paths:
1958 paths = [path] + paths
1959 env['PATH'] = os.pathsep.join(paths)
1960 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1961 env[n] = build.ndk_path()
1964 def replace_build_vars(cmd, build):
1965 cmd = cmd.replace('$$COMMIT$$', build.commit)
1966 cmd = cmd.replace('$$VERSION$$', build.versionName)
1967 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1971 def replace_config_vars(cmd, build):
1972 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1973 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1974 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1975 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1976 if build is not None:
1977 cmd = replace_build_vars(cmd, build)
1981 def place_srclib(root_dir, number, libpath):
1984 relpath = os.path.relpath(libpath, root_dir)
1985 proppath = os.path.join(root_dir, 'project.properties')
1988 if os.path.isfile(proppath):
1989 with open(proppath, "r", encoding='iso-8859-1') as o:
1990 lines = o.readlines()
1992 with open(proppath, "w", encoding='iso-8859-1') as o:
1995 if line.startswith('android.library.reference.%d=' % number):
1996 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2001 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2004 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2007 def metadata_get_sigdir(appid, vercode=None):
2008 """Get signature directory for app"""
2010 return os.path.join('metadata', appid, 'signatures', vercode)
2012 return os.path.join('metadata', appid, 'signatures')
2015 def apk_extract_signatures(apkpath, outdir, manifest=True):
2016 """Extracts a signature files from APK and puts them into target directory.
2018 :param apkpath: location of the apk
2019 :param outdir: folder where the extracted signature files will be stored
2020 :param manifest: (optionally) disable extracting manifest file
2022 with ZipFile(apkpath, 'r') as in_apk:
2023 for f in in_apk.infolist():
2024 if apk_sigfile.match(f.filename) or \
2025 (manifest and f.filename == 'META-INF/MANIFEST.MF'):
2026 newpath = os.path.join(outdir, os.path.basename(f.filename))
2027 with open(newpath, 'wb') as out_file:
2028 out_file.write(in_apk.read(f.filename))
2031 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2032 """Verify that two apks are the same
2034 One of the inputs is signed, the other is unsigned. The signature metadata
2035 is transferred from the signed to the unsigned apk, and then jarsigner is
2036 used to verify that the signature from the signed apk is also varlid for
2037 the unsigned one. If the APK given as unsigned actually does have a
2038 signature, it will be stripped out and ignored.
2040 There are two SHA1 git commit IDs that fdroidserver includes in the builds
2041 it makes: fdroidserverid and buildserverid. Originally, these were inserted
2042 into AndroidManifest.xml, but that makes the build not reproducible. So
2043 instead they are included as separate files in the APK's META-INF/ folder.
2044 If those files exist in the signed APK, they will be part of the signature
2045 and need to also be included in the unsigned APK for it to validate.
2047 :param signed_apk: Path to a signed apk file
2048 :param unsigned_apk: Path to an unsigned apk file expected to match it
2049 :param tmp_dir: Path to directory for temporary files
2050 :returns: None if the verification is successful, otherwise a string
2051 describing what went wrong.
2054 signed = ZipFile(signed_apk, 'r')
2055 meta_inf_files = ['META-INF/MANIFEST.MF']
2056 for f in signed.namelist():
2057 if apk_sigfile.match(f) \
2058 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2059 meta_inf_files.append(f)
2060 if len(meta_inf_files) < 3:
2061 return "Signature files missing from {0}".format(signed_apk)
2063 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2064 unsigned = ZipFile(unsigned_apk, 'r')
2065 # only read the signature from the signed APK, everything else from unsigned
2066 with ZipFile(tmp_apk, 'w') as tmp:
2067 for filename in meta_inf_files:
2068 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2069 for info in unsigned.infolist():
2070 if info.filename in meta_inf_files:
2071 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2073 if info.filename in tmp.namelist():
2074 return "duplicate filename found: " + info.filename
2075 tmp.writestr(info, unsigned.read(info.filename))
2079 verified = verify_apk_signature(tmp_apk)
2082 logging.info("...NOT verified - {0}".format(tmp_apk))
2083 return compare_apks(signed_apk, tmp_apk, tmp_dir,
2084 os.path.dirname(unsigned_apk))
2086 logging.info("...successfully verified")
2090 def verify_jar_signature(jar):
2091 """Verifies the signature of a given JAR file.
2093 jarsigner is very shitty: unsigned JARs pass as "verified"! So
2094 this has to turn on -strict then check for result 4, since this
2095 does not expect the signature to be from a CA-signed certificate.
2097 :raises: VerificationException() if the JAR's signature could not be verified
2101 if subprocess.call([config['jarsigner'], '-strict', '-verify', jar]) != 4:
2102 raise VerificationException(_("The repository's index could not be verified."))
2105 def verify_apk_signature(apk, min_sdk_version=None):
2106 """verify the signature on an APK
2108 Try to use apksigner whenever possible since jarsigner is very
2109 shitty: unsigned APKs pass as "verified"! Warning, this does
2110 not work on JARs with apksigner >= 0.7 (build-tools 26.0.1)
2112 if set_command_in_config('apksigner'):
2113 args = [config['apksigner'], 'verify']
2115 args += ['--min-sdk-version=' + min_sdk_version]
2116 return subprocess.call(args + [apk]) == 0
2118 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2120 verify_jar_signature(apk)
2127 def verify_old_apk_signature(apk):
2128 """verify the signature on an archived APK, supporting deprecated algorithms
2130 F-Droid aims to keep every single binary that it ever published. Therefore,
2131 it needs to be able to verify APK signatures that include deprecated/removed
2132 algorithms. For example, jarsigner treats an MD5 signature as unsigned.
2134 jarsigner passes unsigned APKs as "verified"! So this has to turn
2135 on -strict then check for result 4.
2139 _java_security = os.path.join(os.getcwd(), '.java.security')
2140 with open(_java_security, 'w') as fp:
2141 fp.write('jdk.jar.disabledAlgorithms=MD2, RSA keySize < 1024')
2143 return subprocess.call([config['jarsigner'], '-J-Djava.security.properties=' + _java_security,
2144 '-strict', '-verify', apk]) == 4
2147 apk_badchars = re.compile('''[/ :;'"]''')
2150 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2153 Returns None if the apk content is the same (apart from the signing key),
2154 otherwise a string describing what's different, or what went wrong when
2155 trying to do the comparison.
2161 absapk1 = os.path.abspath(apk1)
2162 absapk2 = os.path.abspath(apk2)
2164 if set_command_in_config('diffoscope'):
2165 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2166 htmlfile = logfilename + '.diffoscope.html'
2167 textfile = logfilename + '.diffoscope.txt'
2168 if subprocess.call([config['diffoscope'],
2169 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2170 '--html', htmlfile, '--text', textfile,
2171 absapk1, absapk2]) != 0:
2172 return("Failed to unpack " + apk1)
2174 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2175 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2176 for d in [apk1dir, apk2dir]:
2177 if os.path.exists(d):
2180 os.mkdir(os.path.join(d, 'jar-xf'))
2182 if subprocess.call(['jar', 'xf',
2183 os.path.abspath(apk1)],
2184 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2185 return("Failed to unpack " + apk1)
2186 if subprocess.call(['jar', 'xf',
2187 os.path.abspath(apk2)],
2188 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2189 return("Failed to unpack " + apk2)
2191 if set_command_in_config('apktool'):
2192 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2194 return("Failed to unpack " + apk1)
2195 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2197 return("Failed to unpack " + apk2)
2199 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2200 lines = p.output.splitlines()
2201 if len(lines) != 1 or 'META-INF' not in lines[0]:
2202 if set_command_in_config('meld'):
2203 p = FDroidPopen([config['meld'], apk1dir, apk2dir], output=False)
2204 return("Unexpected diff output - " + p.output)
2206 # since everything verifies, delete the comparison to keep cruft down
2207 shutil.rmtree(apk1dir)
2208 shutil.rmtree(apk2dir)
2210 # If we get here, it seems like they're the same!
2214 def set_command_in_config(command):
2215 '''Try to find specified command in the path, if it hasn't been
2216 manually set in config.py. If found, it is added to the config
2217 dict. The return value says whether the command is available.
2220 if command in config:
2223 tmp = find_command(command)
2225 config[command] = tmp
2230 def find_command(command):
2231 '''find the full path of a command, or None if it can't be found in the PATH'''
2234 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2236 fpath, fname = os.path.split(command)
2241 for path in os.environ["PATH"].split(os.pathsep):
2242 path = path.strip('"')
2243 exe_file = os.path.join(path, command)
2244 if is_exe(exe_file):
2251 '''generate a random password for when generating keys'''
2252 h = hashlib.sha256()
2253 h.update(os.urandom(16)) # salt
2254 h.update(socket.getfqdn().encode('utf-8'))
2255 passwd = base64.b64encode(h.digest()).strip()
2256 return passwd.decode('utf-8')
2259 def genkeystore(localconfig):
2261 Generate a new key with password provided in :param localconfig and add it to new keystore
2262 :return: hexed public key, public key fingerprint
2264 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2265 keystoredir = os.path.dirname(localconfig['keystore'])
2266 if keystoredir is None or keystoredir == '':
2267 keystoredir = os.path.join(os.getcwd(), keystoredir)
2268 if not os.path.exists(keystoredir):
2269 os.makedirs(keystoredir, mode=0o700)
2272 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2273 'FDROID_KEY_PASS': localconfig['keypass'],
2275 p = FDroidPopen([config['keytool'], '-genkey',
2276 '-keystore', localconfig['keystore'],
2277 '-alias', localconfig['repo_keyalias'],
2278 '-keyalg', 'RSA', '-keysize', '4096',
2279 '-sigalg', 'SHA256withRSA',
2280 '-validity', '10000',
2281 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2282 '-keypass:env', 'FDROID_KEY_PASS',
2283 '-dname', localconfig['keydname']], envs=env_vars)
2284 if p.returncode != 0:
2285 raise BuildException("Failed to generate key", p.output)
2286 os.chmod(localconfig['keystore'], 0o0600)
2287 if not options.quiet:
2288 # now show the lovely key that was just generated
2289 p = FDroidPopen([config['keytool'], '-list', '-v',
2290 '-keystore', localconfig['keystore'],
2291 '-alias', localconfig['repo_keyalias'],
2292 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2293 logging.info(p.output.strip() + '\n\n')
2294 # get the public key
2295 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2296 '-keystore', localconfig['keystore'],
2297 '-alias', localconfig['repo_keyalias'],
2298 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2299 + config['smartcardoptions'],
2300 envs=env_vars, output=False, stderr_to_stdout=False)
2301 if p.returncode != 0 or len(p.output) < 20:
2302 raise BuildException("Failed to get public key", p.output)
2304 fingerprint = get_cert_fingerprint(pubkey)
2305 return hexlify(pubkey), fingerprint
2308 def get_cert_fingerprint(pubkey):
2310 Generate a certificate fingerprint the same way keytool does it
2311 (but with slightly different formatting)
2313 digest = hashlib.sha256(pubkey).digest()
2314 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2315 return " ".join(ret)
2318 def get_certificate(certificate_file):
2320 Extracts a certificate from the given file.
2321 :param certificate_file: file bytes (as string) representing the certificate
2322 :return: A binary representation of the certificate's public key, or None in case of error
2324 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2325 if content.getComponentByName('contentType') != rfc2315.signedData:
2327 content = decoder.decode(content.getComponentByName('content'),
2328 asn1Spec=rfc2315.SignedData())[0]
2330 certificates = content.getComponentByName('certificates')
2331 cert = certificates[0].getComponentByName('certificate')
2333 logging.error("Certificates not found.")
2335 return encoder.encode(cert)
2338 def write_to_config(thisconfig, key, value=None, config_file=None):
2339 '''write a key/value to the local config.py
2341 NOTE: only supports writing string variables.
2343 :param thisconfig: config dictionary
2344 :param key: variable name in config.py to be overwritten/added
2345 :param value: optional value to be written, instead of fetched
2346 from 'thisconfig' dictionary.
2349 origkey = key + '_orig'
2350 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2351 cfg = config_file if config_file else 'config.py'
2353 # load config file, create one if it doesn't exist
2354 if not os.path.exists(cfg):
2355 open(cfg, 'a').close()
2356 logging.info("Creating empty " + cfg)
2357 with open(cfg, 'r', encoding="utf-8") as f:
2358 lines = f.readlines()
2360 # make sure the file ends with a carraige return
2362 if not lines[-1].endswith('\n'):
2365 # regex for finding and replacing python string variable
2366 # definitions/initializations
2367 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2368 repl = key + ' = "' + value + '"'
2369 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2370 repl2 = key + " = '" + value + "'"
2372 # If we replaced this line once, we make sure won't be a
2373 # second instance of this line for this key in the document.
2376 with open(cfg, 'w', encoding="utf-8") as f:
2378 if pattern.match(line) or pattern2.match(line):
2380 line = pattern.sub(repl, line)
2381 line = pattern2.sub(repl2, line)
2392 def parse_xml(path):
2393 return XMLElementTree.parse(path).getroot()
2396 def string_is_integer(string):
2404 def get_per_app_repos():
2405 '''per-app repos are dirs named with the packageName of a single app'''
2407 # Android packageNames are Java packages, they may contain uppercase or
2408 # lowercase letters ('A' through 'Z'), numbers, and underscores
2409 # ('_'). However, individual package name parts may only start with
2410 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2411 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2414 for root, dirs, files in os.walk(os.getcwd()):
2416 print('checking', root, 'for', d)
2417 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2418 # standard parts of an fdroid repo, so never packageNames
2421 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2427 def is_repo_file(filename):
2428 '''Whether the file in a repo is a build product to be delivered to users'''
2429 if isinstance(filename, str):
2430 filename = filename.encode('utf-8', errors="surrogateescape")
2431 return os.path.isfile(filename) \
2432 and not filename.endswith(b'.asc') \
2433 and not filename.endswith(b'.sig') \
2434 and os.path.basename(filename) not in [
2436 b'index_unsigned.jar',