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 import fdroidserver.metadata
50 from .asynchronousfilereader import AsynchronousFileReader
53 # A signature block file with a .DSA, .RSA, or .EC extension
54 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
56 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
65 'sdk_path': "$ANDROID_HOME",
70 'r12b': "$ANDROID_NDK",
75 'build_tools': "25.0.2",
76 'force_build_tools': False,
81 'accepted_formats': ['txt', 'yml'],
82 'sync_from_local_copy_dir': False,
83 'per_app_repos': False,
84 'make_current_version_link': True,
85 'current_version_name_source': 'Name',
86 'update_stats': False,
90 'stats_to_carbon': False,
92 'build_server_always': False,
93 'keystore': 'keystore.jks',
94 'smartcardoptions': [],
104 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
105 'repo_name': "My First FDroid Repo Demo",
106 'repo_icon': "fdroid-icon.png",
107 'repo_description': '''
108 This is a repository of apps to be used with FDroid. Applications in this
109 repository are either official binaries built by the original application
110 developers, or are binaries built from source by the admin of f-droid.org
111 using the tools on https://gitlab.com/u/fdroid.
117 def setup_global_opts(parser):
118 parser.add_argument("-v", "--verbose", action="store_true", default=False,
119 help="Spew out even more information than normal")
120 parser.add_argument("-q", "--quiet", action="store_true", default=False,
121 help="Restrict output to warnings and errors")
124 def fill_config_defaults(thisconfig):
125 for k, v in default_config.items():
126 if k not in thisconfig:
129 # Expand paths (~users and $vars)
130 def expand_path(path):
134 path = os.path.expanduser(path)
135 path = os.path.expandvars(path)
140 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
145 thisconfig[k + '_orig'] = v
147 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
148 if thisconfig['java_paths'] is None:
149 thisconfig['java_paths'] = dict()
151 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
152 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
153 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
154 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
155 if os.getenv('JAVA_HOME') is not None:
156 pathlist.append(os.getenv('JAVA_HOME'))
157 if os.getenv('PROGRAMFILES') is not None:
158 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
159 for d in sorted(pathlist):
160 if os.path.islink(d):
162 j = os.path.basename(d)
163 # the last one found will be the canonical one, so order appropriately
165 r'^1\.([6-9])\.0\.jdk$', # OSX
166 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
167 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
168 r'^jdk([6-9])-openjdk$', # Arch
169 r'^java-([6-9])-openjdk$', # Arch
170 r'^java-([6-9])-jdk$', # Arch (oracle)
171 r'^java-1\.([6-9])\.0-.*$', # RedHat
172 r'^java-([6-9])-oracle$', # Debian WebUpd8
173 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
174 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
176 m = re.match(regex, j)
179 for p in [d, os.path.join(d, 'Contents', 'Home')]:
180 if os.path.exists(os.path.join(p, 'bin', 'javac')):
181 thisconfig['java_paths'][m.group(1)] = p
183 for java_version in ('7', '8', '9'):
184 if java_version not in thisconfig['java_paths']:
186 java_home = thisconfig['java_paths'][java_version]
187 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
188 if os.path.exists(jarsigner):
189 thisconfig['jarsigner'] = jarsigner
190 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
191 break # Java7 is preferred, so quit if found
193 for k in ['ndk_paths', 'java_paths']:
199 thisconfig[k][k2] = exp
200 thisconfig[k][k2 + '_orig'] = v
203 def regsub_file(pattern, repl, path):
204 with open(path, 'rb') as f:
206 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
207 with open(path, 'wb') as f:
211 def read_config(opts, config_file='config.py'):
212 """Read the repository config
214 The config is read from config_file, which is in the current
215 directory when any of the repo management commands are used. If
216 there is a local metadata file in the git repo, then config.py is
217 not required, just use defaults.
220 global config, options
222 if config is not None:
229 if os.path.isfile(config_file):
230 logging.debug("Reading %s" % config_file)
231 with io.open(config_file, "rb") as f:
232 code = compile(f.read(), config_file, 'exec')
233 exec(code, None, config)
234 elif len(get_local_metadata_files()) == 0:
235 logging.critical("Missing config file - is this a repo directory?")
238 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
240 if not type(config[k]) in (str, list, tuple):
241 logging.warn('"' + k + '" will be in random order!'
242 + ' Use () or [] brackets if order is important!')
244 # smartcardoptions must be a list since its command line args for Popen
245 if 'smartcardoptions' in config:
246 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
247 elif 'keystore' in config and config['keystore'] == 'NONE':
248 # keystore='NONE' means use smartcard, these are required defaults
249 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
250 'SunPKCS11-OpenSC', '-providerClass',
251 'sun.security.pkcs11.SunPKCS11',
252 '-providerArg', 'opensc-fdroid.cfg']
254 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
255 st = os.stat(config_file)
256 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
257 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
259 fill_config_defaults(config)
261 for k in ["repo_description", "archive_description"]:
263 config[k] = clean_description(config[k])
265 if 'serverwebroot' in config:
266 if isinstance(config['serverwebroot'], str):
267 roots = [config['serverwebroot']]
268 elif all(isinstance(item, str) for item in config['serverwebroot']):
269 roots = config['serverwebroot']
271 raise TypeError('only accepts strings, lists, and tuples')
273 for rootstr in roots:
274 # since this is used with rsync, where trailing slashes have
275 # meaning, ensure there is always a trailing slash
276 if rootstr[-1] != '/':
278 rootlist.append(rootstr.replace('//', '/'))
279 config['serverwebroot'] = rootlist
281 if 'servergitmirrors' in config:
282 if isinstance(config['servergitmirrors'], str):
283 roots = [config['servergitmirrors']]
284 elif all(isinstance(item, str) for item in config['servergitmirrors']):
285 roots = config['servergitmirrors']
287 raise TypeError('only accepts strings, lists, and tuples')
288 config['servergitmirrors'] = roots
293 def find_sdk_tools_cmd(cmd):
294 '''find a working path to a tool from the Android SDK'''
297 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
298 # try to find a working path to this command, in all the recent possible paths
299 if 'build_tools' in config:
300 build_tools = os.path.join(config['sdk_path'], 'build-tools')
301 # if 'build_tools' was manually set and exists, check only that one
302 configed_build_tools = os.path.join(build_tools, config['build_tools'])
303 if os.path.exists(configed_build_tools):
304 tooldirs.append(configed_build_tools)
306 # no configed version, so hunt known paths for it
307 for f in sorted(os.listdir(build_tools), reverse=True):
308 if os.path.isdir(os.path.join(build_tools, f)):
309 tooldirs.append(os.path.join(build_tools, f))
310 tooldirs.append(build_tools)
311 sdk_tools = os.path.join(config['sdk_path'], 'tools')
312 if os.path.exists(sdk_tools):
313 tooldirs.append(sdk_tools)
314 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
315 if os.path.exists(sdk_platform_tools):
316 tooldirs.append(sdk_platform_tools)
317 tooldirs.append('/usr/bin')
319 path = os.path.join(d, cmd)
320 if os.path.isfile(path):
322 test_aapt_version(path)
324 # did not find the command, exit with error message
325 ensure_build_tools_exists(config)
328 def test_aapt_version(aapt):
329 '''Check whether the version of aapt is new enough'''
330 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
331 if output is None or output == '':
332 logging.error(aapt + ' failed to execute!')
334 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
339 # the Debian package has the version string like "v0.2-23.0.2"
340 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
341 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
343 logging.warning('Unknown version of aapt, might cause problems: ' + output)
346 def test_sdk_exists(thisconfig):
347 if 'sdk_path' not in thisconfig:
348 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
349 test_aapt_version(thisconfig['aapt'])
352 logging.error("'sdk_path' not set in config.py!")
354 if thisconfig['sdk_path'] == default_config['sdk_path']:
355 logging.error('No Android SDK found!')
356 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
357 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
359 if not os.path.exists(thisconfig['sdk_path']):
360 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
362 if not os.path.isdir(thisconfig['sdk_path']):
363 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
365 for d in ['build-tools', 'platform-tools', 'tools']:
366 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
367 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
368 thisconfig['sdk_path'], d))
373 def ensure_build_tools_exists(thisconfig):
374 if not test_sdk_exists(thisconfig):
376 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
377 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
378 if not os.path.isdir(versioned_build_tools):
379 logging.critical('Android Build Tools path "'
380 + versioned_build_tools + '" does not exist!')
384 def get_local_metadata_files():
385 '''get any metadata files local to an app's source repo
387 This tries to ignore anything that does not count as app metdata,
388 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
391 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
394 def read_pkg_args(args, allow_vercodes=False):
396 Given the arguments in the form of multiple appid:[vc] strings, this returns
397 a dictionary with the set of vercodes specified for each package.
405 if allow_vercodes and ':' in p:
406 package, vercode = p.split(':')
408 package, vercode = p, None
409 if package not in vercodes:
410 vercodes[package] = [vercode] if vercode else []
412 elif vercode and vercode not in vercodes[package]:
413 vercodes[package] += [vercode] if vercode else []
418 def read_app_args(args, allapps, allow_vercodes=False):
420 On top of what read_pkg_args does, this returns the whole app metadata, but
421 limiting the builds list to the builds matching the vercodes specified.
424 vercodes = read_pkg_args(args, allow_vercodes)
430 for appid, app in allapps.items():
431 if appid in vercodes:
434 if len(apps) != len(vercodes):
437 logging.critical("No such package: %s" % p)
438 raise FDroidException("Found invalid app ids in arguments")
440 raise FDroidException("No packages specified")
443 for appid, app in apps.items():
447 app.builds = [b for b in app.builds if b.versionCode in vc]
448 if len(app.builds) != len(vercodes[appid]):
450 allvcs = [b.versionCode for b in app.builds]
451 for v in vercodes[appid]:
453 logging.critical("No such vercode %s for app %s" % (v, appid))
456 raise FDroidException("Found invalid vercodes for some apps")
461 def get_extension(filename):
462 base, ext = os.path.splitext(filename)
465 return base, ext.lower()[1:]
468 def has_extension(filename, ext):
469 _, f_ext = get_extension(filename)
473 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
476 def clean_description(description):
477 'Remove unneeded newlines and spaces from a block of description text'
479 # this is split up by paragraph to make removing the newlines easier
480 for paragraph in re.split(r'\n\n', description):
481 paragraph = re.sub('\r', '', paragraph)
482 paragraph = re.sub('\n', ' ', paragraph)
483 paragraph = re.sub(' {2,}', ' ', paragraph)
484 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
485 returnstring += paragraph + '\n\n'
486 return returnstring.rstrip('\n')
489 def publishednameinfo(filename):
490 filename = os.path.basename(filename)
491 m = publish_name_regex.match(filename)
493 result = (m.group(1), m.group(2))
494 except AttributeError:
495 raise FDroidException("Invalid name for published file: %s" % filename)
499 def get_release_filename(app, build):
501 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
503 return "%s_%s.apk" % (app.id, build.versionCode)
506 def get_toolsversion_logname(app, build):
507 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
510 def getsrcname(app, build):
511 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
523 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
526 def get_build_dir(app):
527 '''get the dir that this app will be built in'''
529 if app.RepoType == 'srclib':
530 return os.path.join('build', 'srclib', app.Repo)
532 return os.path.join('build', app.id)
536 '''checkout code from VCS and return instance of vcs and the build dir'''
537 build_dir = get_build_dir(app)
539 # Set up vcs interface and make sure we have the latest code...
540 logging.debug("Getting {0} vcs interface for {1}"
541 .format(app.RepoType, app.Repo))
542 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
546 vcs = getvcs(app.RepoType, remote, build_dir)
548 return vcs, build_dir
551 def getvcs(vcstype, remote, local):
553 return vcs_git(remote, local)
554 if vcstype == 'git-svn':
555 return vcs_gitsvn(remote, local)
557 return vcs_hg(remote, local)
559 return vcs_bzr(remote, local)
560 if vcstype == 'srclib':
561 if local != os.path.join('build', 'srclib', remote):
562 raise VCSException("Error: srclib paths are hard-coded!")
563 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
565 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
566 raise VCSException("Invalid vcs type " + vcstype)
569 def getsrclibvcs(name):
570 if name not in fdroidserver.metadata.srclibs:
571 raise VCSException("Missing srclib " + name)
572 return fdroidserver.metadata.srclibs[name]['Repo Type']
577 def __init__(self, remote, local):
579 # svn, git-svn and bzr may require auth
581 if self.repotype() in ('git-svn', 'bzr'):
583 if self.repotype == 'git-svn':
584 raise VCSException("Authentication is not supported for git-svn")
585 self.username, remote = remote.split('@')
586 if ':' not in self.username:
587 raise VCSException("Password required with username")
588 self.username, self.password = self.username.split(':')
592 self.clone_failed = False
593 self.refreshed = False
599 # Take the local repository to a clean version of the given revision, which
600 # is specificed in the VCS's native format. Beforehand, the repository can
601 # be dirty, or even non-existent. If the repository does already exist
602 # locally, it will be updated from the origin, but only once in the
603 # lifetime of the vcs object.
604 # None is acceptable for 'rev' if you know you are cloning a clean copy of
605 # the repo - otherwise it must specify a valid revision.
606 def gotorevision(self, rev, refresh=True):
608 if self.clone_failed:
609 raise VCSException("Downloading the repository already failed once, not trying again.")
611 # The .fdroidvcs-id file for a repo tells us what VCS type
612 # and remote that directory was created from, allowing us to drop it
613 # automatically if either of those things changes.
614 fdpath = os.path.join(self.local, '..',
615 '.fdroidvcs-' + os.path.basename(self.local))
616 fdpath = os.path.normpath(fdpath)
617 cdata = self.repotype() + ' ' + self.remote
620 if os.path.exists(self.local):
621 if os.path.exists(fdpath):
622 with open(fdpath, 'r') as f:
623 fsdata = f.read().strip()
628 logging.info("Repository details for %s changed - deleting" % (
632 logging.info("Repository details for %s missing - deleting" % (
635 shutil.rmtree(self.local)
639 self.refreshed = True
642 self.gotorevisionx(rev)
643 except FDroidException as e:
646 # If necessary, write the .fdroidvcs file.
647 if writeback and not self.clone_failed:
648 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
649 with open(fdpath, 'w+') as f:
655 # Derived classes need to implement this. It's called once basic checking
656 # has been performend.
657 def gotorevisionx(self, rev):
658 raise VCSException("This VCS type doesn't define gotorevisionx")
660 # Initialise and update submodules
661 def initsubmodules(self):
662 raise VCSException('Submodules not supported for this vcs type')
664 # Get a list of all known tags
666 if not self._gettags:
667 raise VCSException('gettags not supported for this vcs type')
669 for tag in self._gettags():
670 if re.match('[-A-Za-z0-9_. /]+$', tag):
674 # Get a list of all the known tags, sorted from newest to oldest
675 def latesttags(self):
676 raise VCSException('latesttags not supported for this vcs type')
678 # Get current commit reference (hash, revision, etc)
680 raise VCSException('getref not supported for this vcs type')
682 # Returns the srclib (name, path) used in setting up the current
693 # If the local directory exists, but is somehow not a git repository, git
694 # will traverse up the directory tree until it finds one that is (i.e.
695 # fdroidserver) and then we'll proceed to destroy it! This is called as
698 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
699 result = p.output.rstrip()
700 if not result.endswith(self.local):
701 raise VCSException('Repository mismatch')
703 def gotorevisionx(self, rev):
704 if not os.path.exists(self.local):
706 p = FDroidPopen(['git', 'clone', self.remote, self.local])
707 if p.returncode != 0:
708 self.clone_failed = True
709 raise VCSException("Git clone failed", p.output)
713 # Discard any working tree changes
714 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
715 'git', 'reset', '--hard'], cwd=self.local, output=False)
716 if p.returncode != 0:
717 raise VCSException("Git reset failed", p.output)
718 # Remove untracked files now, in case they're tracked in the target
719 # revision (it happens!)
720 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
721 'git', 'clean', '-dffx'], cwd=self.local, output=False)
722 if p.returncode != 0:
723 raise VCSException("Git clean failed", p.output)
724 if not self.refreshed:
725 # Get latest commits and tags from remote
726 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
727 if p.returncode != 0:
728 raise VCSException("Git fetch failed", p.output)
729 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
730 if p.returncode != 0:
731 raise VCSException("Git fetch failed", p.output)
732 # Recreate origin/HEAD as git clone would do it, in case it disappeared
733 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
734 if p.returncode != 0:
735 lines = p.output.splitlines()
736 if 'Multiple remote HEAD branches' not in lines[0]:
737 raise VCSException("Git remote set-head failed", p.output)
738 branch = lines[1].split(' ')[-1]
739 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
740 if p2.returncode != 0:
741 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
742 self.refreshed = True
743 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
744 # a github repo. Most of the time this is the same as origin/master.
745 rev = rev or 'origin/HEAD'
746 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
747 if p.returncode != 0:
748 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
749 # Get rid of any uncontrolled files left behind
750 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
751 if p.returncode != 0:
752 raise VCSException("Git clean failed", p.output)
754 def initsubmodules(self):
756 submfile = os.path.join(self.local, '.gitmodules')
757 if not os.path.isfile(submfile):
758 raise VCSException("No git submodules available")
760 # fix submodules not accessible without an account and public key auth
761 with open(submfile, 'r') as f:
762 lines = f.readlines()
763 with open(submfile, 'w') as f:
765 if 'git@github.com' in line:
766 line = line.replace('git@github.com:', 'https://github.com/')
767 if 'git@gitlab.com' in line:
768 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
771 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
772 if p.returncode != 0:
773 raise VCSException("Git submodule sync failed", p.output)
774 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
775 if p.returncode != 0:
776 raise VCSException("Git submodule update failed", p.output)
780 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
781 return p.output.splitlines()
783 tag_format = re.compile(r'tag: ([^),]*)')
785 def latesttags(self):
787 p = FDroidPopen(['git', 'log', '--tags',
788 '--simplify-by-decoration', '--pretty=format:%d'],
789 cwd=self.local, output=False)
791 for line in p.output.splitlines():
792 for tag in self.tag_format.findall(line):
797 class vcs_gitsvn(vcs):
802 # If the local directory exists, but is somehow not a git repository, git
803 # will traverse up the directory tree until it finds one that is (i.e.
804 # fdroidserver) and then we'll proceed to destory it! This is called as
807 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
808 result = p.output.rstrip()
809 if not result.endswith(self.local):
810 raise VCSException('Repository mismatch')
812 def gotorevisionx(self, rev):
813 if not os.path.exists(self.local):
815 gitsvn_args = ['git', 'svn', 'clone']
816 if ';' in self.remote:
817 remote_split = self.remote.split(';')
818 for i in remote_split[1:]:
819 if i.startswith('trunk='):
820 gitsvn_args.extend(['-T', i[6:]])
821 elif i.startswith('tags='):
822 gitsvn_args.extend(['-t', i[5:]])
823 elif i.startswith('branches='):
824 gitsvn_args.extend(['-b', i[9:]])
825 gitsvn_args.extend([remote_split[0], self.local])
826 p = FDroidPopen(gitsvn_args, output=False)
827 if p.returncode != 0:
828 self.clone_failed = True
829 raise VCSException("Git svn clone failed", p.output)
831 gitsvn_args.extend([self.remote, self.local])
832 p = FDroidPopen(gitsvn_args, output=False)
833 if p.returncode != 0:
834 self.clone_failed = True
835 raise VCSException("Git svn clone failed", p.output)
839 # Discard any working tree changes
840 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
841 if p.returncode != 0:
842 raise VCSException("Git reset failed", p.output)
843 # Remove untracked files now, in case they're tracked in the target
844 # revision (it happens!)
845 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
846 if p.returncode != 0:
847 raise VCSException("Git clean failed", p.output)
848 if not self.refreshed:
849 # Get new commits, branches and tags from repo
850 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
851 if p.returncode != 0:
852 raise VCSException("Git svn fetch failed")
853 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
854 if p.returncode != 0:
855 raise VCSException("Git svn rebase failed", p.output)
856 self.refreshed = True
858 rev = rev or 'master'
860 nospaces_rev = rev.replace(' ', '%20')
861 # Try finding a svn tag
862 for treeish in ['origin/', '']:
863 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
864 if p.returncode == 0:
866 if p.returncode != 0:
867 # No tag found, normal svn rev translation
868 # Translate svn rev into git format
869 rev_split = rev.split('/')
872 for treeish in ['origin/', '']:
873 if len(rev_split) > 1:
874 treeish += rev_split[0]
875 svn_rev = rev_split[1]
878 # if no branch is specified, then assume trunk (i.e. 'master' branch):
882 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
884 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
885 git_rev = p.output.rstrip()
887 if p.returncode == 0 and git_rev:
890 if p.returncode != 0 or not git_rev:
891 # Try a plain git checkout as a last resort
892 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
893 if p.returncode != 0:
894 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
896 # Check out the git rev equivalent to the svn rev
897 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
898 if p.returncode != 0:
899 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
901 # Get rid of any uncontrolled files left behind
902 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
903 if p.returncode != 0:
904 raise VCSException("Git clean failed", p.output)
908 for treeish in ['origin/', '']:
909 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
915 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
916 if p.returncode != 0:
918 return p.output.strip()
926 def gotorevisionx(self, rev):
927 if not os.path.exists(self.local):
928 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
929 if p.returncode != 0:
930 self.clone_failed = True
931 raise VCSException("Hg clone failed", p.output)
933 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
934 if p.returncode != 0:
935 raise VCSException("Hg status failed", p.output)
936 for line in p.output.splitlines():
937 if not line.startswith('? '):
938 raise VCSException("Unexpected output from hg status -uS: " + line)
939 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
940 if not self.refreshed:
941 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
942 if p.returncode != 0:
943 raise VCSException("Hg pull failed", p.output)
944 self.refreshed = True
946 rev = rev or 'default'
949 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
950 if p.returncode != 0:
951 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
952 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
953 # Also delete untracked files, we have to enable purge extension for that:
954 if "'purge' is provided by the following extension" in p.output:
955 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
956 myfile.write("\n[extensions]\nhgext.purge=\n")
957 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
958 if p.returncode != 0:
959 raise VCSException("HG purge failed", p.output)
960 elif p.returncode != 0:
961 raise VCSException("HG purge failed", p.output)
964 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
965 return p.output.splitlines()[1:]
973 def gotorevisionx(self, rev):
974 if not os.path.exists(self.local):
975 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
976 if p.returncode != 0:
977 self.clone_failed = True
978 raise VCSException("Bzr branch failed", p.output)
980 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
981 if p.returncode != 0:
982 raise VCSException("Bzr revert failed", p.output)
983 if not self.refreshed:
984 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
985 if p.returncode != 0:
986 raise VCSException("Bzr update failed", p.output)
987 self.refreshed = True
989 revargs = list(['-r', rev] if rev else [])
990 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
991 if p.returncode != 0:
992 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
995 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
996 return [tag.split(' ')[0].strip() for tag in
997 p.output.splitlines()]
1000 def unescape_string(string):
1003 if string[0] == '"' and string[-1] == '"':
1006 return string.replace("\\'", "'")
1009 def retrieve_string(app_dir, string, xmlfiles=None):
1011 if not string.startswith('@string/'):
1012 return unescape_string(string)
1014 if xmlfiles is None:
1017 os.path.join(app_dir, 'res'),
1018 os.path.join(app_dir, 'src', 'main', 'res'),
1020 for r, d, f in os.walk(res_dir):
1021 if os.path.basename(r) == 'values':
1022 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1024 name = string[len('@string/'):]
1026 def element_content(element):
1027 if element.text is None:
1029 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1030 return s.decode('utf-8').strip()
1032 for path in xmlfiles:
1033 if not os.path.isfile(path):
1035 xml = parse_xml(path)
1036 element = xml.find('string[@name="' + name + '"]')
1037 if element is not None:
1038 content = element_content(element)
1039 return retrieve_string(app_dir, content, xmlfiles)
1044 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1045 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1048 def manifest_paths(app_dir, flavours):
1049 '''Return list of existing files that will be used to find the highest vercode'''
1051 possible_manifests = \
1052 [os.path.join(app_dir, 'AndroidManifest.xml'),
1053 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1054 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1055 os.path.join(app_dir, 'build.gradle')]
1057 for flavour in flavours:
1058 if flavour == 'yes':
1060 possible_manifests.append(
1061 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1063 return [path for path in possible_manifests if os.path.isfile(path)]
1066 def fetch_real_name(app_dir, flavours):
1067 '''Retrieve the package name. Returns the name, or None if not found.'''
1068 for path in manifest_paths(app_dir, flavours):
1069 if not has_extension(path, 'xml') or not os.path.isfile(path):
1071 logging.debug("fetch_real_name: Checking manifest at " + path)
1072 xml = parse_xml(path)
1073 app = xml.find('application')
1076 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1078 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1079 result = retrieve_string_singleline(app_dir, label)
1081 result = result.strip()
1086 def get_library_references(root_dir):
1088 proppath = os.path.join(root_dir, 'project.properties')
1089 if not os.path.isfile(proppath):
1091 with open(proppath, 'r', encoding='iso-8859-1') as f:
1093 if not line.startswith('android.library.reference.'):
1095 path = line.split('=')[1].strip()
1096 relpath = os.path.join(root_dir, path)
1097 if not os.path.isdir(relpath):
1099 logging.debug("Found subproject at %s" % path)
1100 libraries.append(path)
1104 def ant_subprojects(root_dir):
1105 subprojects = get_library_references(root_dir)
1106 for subpath in subprojects:
1107 subrelpath = os.path.join(root_dir, subpath)
1108 for p in get_library_references(subrelpath):
1109 relp = os.path.normpath(os.path.join(subpath, p))
1110 if relp not in subprojects:
1111 subprojects.insert(0, relp)
1115 def remove_debuggable_flags(root_dir):
1116 # Remove forced debuggable flags
1117 logging.debug("Removing debuggable flags from %s" % root_dir)
1118 for root, dirs, files in os.walk(root_dir):
1119 if 'AndroidManifest.xml' in files:
1120 regsub_file(r'android:debuggable="[^"]*"',
1122 os.path.join(root, 'AndroidManifest.xml'))
1125 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1126 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1127 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1130 def app_matches_packagename(app, package):
1133 appid = app.UpdateCheckName or app.id
1134 if appid is None or appid == "Ignore":
1136 return appid == package
1139 def parse_androidmanifests(paths, app):
1141 Extract some information from the AndroidManifest.xml at the given path.
1142 Returns (version, vercode, package), any or all of which might be None.
1143 All values returned are strings.
1146 ignoreversions = app.UpdateCheckIgnore
1147 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1150 return (None, None, None)
1158 if not os.path.isfile(path):
1161 logging.debug("Parsing manifest at {0}".format(path))
1166 if has_extension(path, 'gradle'):
1167 with open(path, 'r') as f:
1169 if gradle_comment.match(line):
1171 # Grab first occurence of each to avoid running into
1172 # alternative flavours and builds.
1174 matches = psearch_g(line)
1176 s = matches.group(2)
1177 if app_matches_packagename(app, s):
1180 matches = vnsearch_g(line)
1182 version = matches.group(2)
1184 matches = vcsearch_g(line)
1186 vercode = matches.group(1)
1189 xml = parse_xml(path)
1190 if "package" in xml.attrib:
1191 s = xml.attrib["package"]
1192 if app_matches_packagename(app, s):
1194 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1195 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1196 base_dir = os.path.dirname(path)
1197 version = retrieve_string_singleline(base_dir, version)
1198 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1199 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1200 if string_is_integer(a):
1203 logging.warning("Problem with xml at {0}".format(path))
1205 # Remember package name, may be defined separately from version+vercode
1207 package = max_package
1209 logging.debug("..got package={0}, version={1}, vercode={2}"
1210 .format(package, version, vercode))
1212 # Always grab the package name and version name in case they are not
1213 # together with the highest version code
1214 if max_package is None and package is not None:
1215 max_package = package
1216 if max_version is None and version is not None:
1217 max_version = version
1219 if vercode is not None \
1220 and (max_vercode is None or vercode > max_vercode):
1221 if not ignoresearch or not ignoresearch(version):
1222 if version is not None:
1223 max_version = version
1224 if vercode is not None:
1225 max_vercode = vercode
1226 if package is not None:
1227 max_package = package
1229 max_version = "Ignore"
1231 if max_version is None:
1232 max_version = "Unknown"
1234 if max_package and not is_valid_package_name(max_package):
1235 raise FDroidException("Invalid package name {0}".format(max_package))
1237 return (max_version, max_vercode, max_package)
1240 def is_valid_package_name(name):
1241 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1244 class FDroidException(Exception):
1246 def __init__(self, value, detail=None):
1248 self.detail = detail
1250 def shortened_detail(self):
1251 if len(self.detail) < 16000:
1253 return '[...]\n' + self.detail[-16000:]
1255 def get_wikitext(self):
1256 ret = repr(self.value) + "\n"
1259 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1265 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1269 class VCSException(FDroidException):
1273 class BuildException(FDroidException):
1277 # Get the specified source library.
1278 # Returns the path to it. Normally this is the path to be used when referencing
1279 # it, which may be a subdirectory of the actual project. If you want the base
1280 # directory of the project, pass 'basepath=True'.
1281 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1282 raw=False, prepare=True, preponly=False, refresh=True,
1291 name, ref = spec.split('@')
1293 number, name = name.split(':', 1)
1295 name, subdir = name.split('/', 1)
1297 if name not in fdroidserver.metadata.srclibs:
1298 raise VCSException('srclib ' + name + ' not found.')
1300 srclib = fdroidserver.metadata.srclibs[name]
1302 sdir = os.path.join(srclib_dir, name)
1305 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1306 vcs.srclib = (name, number, sdir)
1308 vcs.gotorevision(ref, refresh)
1315 libdir = os.path.join(sdir, subdir)
1316 elif srclib["Subdir"]:
1317 for subdir in srclib["Subdir"]:
1318 libdir_candidate = os.path.join(sdir, subdir)
1319 if os.path.exists(libdir_candidate):
1320 libdir = libdir_candidate
1326 remove_signing_keys(sdir)
1327 remove_debuggable_flags(sdir)
1331 if srclib["Prepare"]:
1332 cmd = replace_config_vars(srclib["Prepare"], build)
1334 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1335 if p.returncode != 0:
1336 raise BuildException("Error running prepare command for srclib %s"
1342 return (name, number, libdir)
1345 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1348 # Prepare the source code for a particular build
1349 # 'vcs' - the appropriate vcs object for the application
1350 # 'app' - the application details from the metadata
1351 # 'build' - the build details from the metadata
1352 # 'build_dir' - the path to the build directory, usually
1354 # 'srclib_dir' - the path to the source libraries directory, usually
1356 # 'extlib_dir' - the path to the external libraries directory, usually
1358 # Returns the (root, srclibpaths) where:
1359 # 'root' is the root directory, which may be the same as 'build_dir' or may
1360 # be a subdirectory of it.
1361 # 'srclibpaths' is information on the srclibs being used
1362 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1364 # Optionally, the actual app source can be in a subdirectory
1366 root_dir = os.path.join(build_dir, build.subdir)
1368 root_dir = build_dir
1370 # Get a working copy of the right revision
1371 logging.info("Getting source for revision " + build.commit)
1372 vcs.gotorevision(build.commit, refresh)
1374 # Initialise submodules if required
1375 if build.submodules:
1376 logging.info("Initialising submodules")
1377 vcs.initsubmodules()
1379 # Check that a subdir (if we're using one) exists. This has to happen
1380 # after the checkout, since it might not exist elsewhere
1381 if not os.path.exists(root_dir):
1382 raise BuildException('Missing subdir ' + root_dir)
1384 # Run an init command if one is required
1386 cmd = replace_config_vars(build.init, build)
1387 logging.info("Running 'init' commands in %s" % root_dir)
1389 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1390 if p.returncode != 0:
1391 raise BuildException("Error running init command for %s:%s" %
1392 (app.id, build.versionName), p.output)
1394 # Apply patches if any
1396 logging.info("Applying patches")
1397 for patch in build.patch:
1398 patch = patch.strip()
1399 logging.info("Applying " + patch)
1400 patch_path = os.path.join('metadata', app.id, patch)
1401 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1402 if p.returncode != 0:
1403 raise BuildException("Failed to apply patch %s" % patch_path)
1405 # Get required source libraries
1408 logging.info("Collecting source libraries")
1409 for lib in build.srclibs:
1410 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1411 refresh=refresh, build=build))
1413 for name, number, libpath in srclibpaths:
1414 place_srclib(root_dir, int(number) if number else None, libpath)
1416 basesrclib = vcs.getsrclib()
1417 # If one was used for the main source, add that too.
1419 srclibpaths.append(basesrclib)
1421 # Update the local.properties file
1422 localprops = [os.path.join(build_dir, 'local.properties')]
1424 parts = build.subdir.split(os.sep)
1427 cur = os.path.join(cur, d)
1428 localprops += [os.path.join(cur, 'local.properties')]
1429 for path in localprops:
1431 if os.path.isfile(path):
1432 logging.info("Updating local.properties file at %s" % path)
1433 with open(path, 'r', encoding='iso-8859-1') as f:
1437 logging.info("Creating local.properties file at %s" % path)
1438 # Fix old-fashioned 'sdk-location' by copying
1439 # from sdk.dir, if necessary
1441 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1442 re.S | re.M).group(1)
1443 props += "sdk-location=%s\n" % sdkloc
1445 props += "sdk.dir=%s\n" % config['sdk_path']
1446 props += "sdk-location=%s\n" % config['sdk_path']
1447 ndk_path = build.ndk_path()
1448 # if for any reason the path isn't valid or the directory
1449 # doesn't exist, some versions of Gradle will error with a
1450 # cryptic message (even if the NDK is not even necessary).
1451 # https://gitlab.com/fdroid/fdroidserver/issues/171
1452 if ndk_path and os.path.exists(ndk_path):
1454 props += "ndk.dir=%s\n" % ndk_path
1455 props += "ndk-location=%s\n" % ndk_path
1456 # Add java.encoding if necessary
1458 props += "java.encoding=%s\n" % build.encoding
1459 with open(path, 'w', encoding='iso-8859-1') as f:
1463 if build.build_method() == 'gradle':
1464 flavours = build.gradle
1467 n = build.target.split('-')[1]
1468 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1469 r'compileSdkVersion %s' % n,
1470 os.path.join(root_dir, 'build.gradle'))
1472 # Remove forced debuggable flags
1473 remove_debuggable_flags(root_dir)
1475 # Insert version code and number into the manifest if necessary
1476 if build.forceversion:
1477 logging.info("Changing the version name")
1478 for path in manifest_paths(root_dir, flavours):
1479 if not os.path.isfile(path):
1481 if has_extension(path, 'xml'):
1482 regsub_file(r'android:versionName="[^"]*"',
1483 r'android:versionName="%s"' % build.versionName,
1485 elif has_extension(path, 'gradle'):
1486 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1487 r"""\1versionName '%s'""" % build.versionName,
1490 if build.forcevercode:
1491 logging.info("Changing the version code")
1492 for path in manifest_paths(root_dir, flavours):
1493 if not os.path.isfile(path):
1495 if has_extension(path, 'xml'):
1496 regsub_file(r'android:versionCode="[^"]*"',
1497 r'android:versionCode="%s"' % build.versionCode,
1499 elif has_extension(path, 'gradle'):
1500 regsub_file(r'versionCode[ =]+[0-9]+',
1501 r'versionCode %s' % build.versionCode,
1504 # Delete unwanted files
1506 logging.info("Removing specified files")
1507 for part in getpaths(build_dir, build.rm):
1508 dest = os.path.join(build_dir, part)
1509 logging.info("Removing {0}".format(part))
1510 if os.path.lexists(dest):
1511 if os.path.islink(dest):
1512 FDroidPopen(['unlink', dest], output=False)
1514 FDroidPopen(['rm', '-rf', dest], output=False)
1516 logging.info("...but it didn't exist")
1518 remove_signing_keys(build_dir)
1520 # Add required external libraries
1522 logging.info("Collecting prebuilt libraries")
1523 libsdir = os.path.join(root_dir, 'libs')
1524 if not os.path.exists(libsdir):
1526 for lib in build.extlibs:
1528 logging.info("...installing extlib {0}".format(lib))
1529 libf = os.path.basename(lib)
1530 libsrc = os.path.join(extlib_dir, lib)
1531 if not os.path.exists(libsrc):
1532 raise BuildException("Missing extlib file {0}".format(libsrc))
1533 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1535 # Run a pre-build command if one is required
1537 logging.info("Running 'prebuild' commands in %s" % root_dir)
1539 cmd = replace_config_vars(build.prebuild, build)
1541 # Substitute source library paths into prebuild commands
1542 for name, number, libpath in srclibpaths:
1543 libpath = os.path.relpath(libpath, root_dir)
1544 cmd = cmd.replace('$$' + name + '$$', libpath)
1546 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1547 if p.returncode != 0:
1548 raise BuildException("Error running prebuild command for %s:%s" %
1549 (app.id, build.versionName), p.output)
1551 # Generate (or update) the ant build file, build.xml...
1552 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1553 parms = ['android', 'update', 'lib-project']
1554 lparms = ['android', 'update', 'project']
1557 parms += ['-t', build.target]
1558 lparms += ['-t', build.target]
1559 if build.androidupdate:
1560 update_dirs = build.androidupdate
1562 update_dirs = ant_subprojects(root_dir) + ['.']
1564 for d in update_dirs:
1565 subdir = os.path.join(root_dir, d)
1567 logging.debug("Updating main project")
1568 cmd = parms + ['-p', d]
1570 logging.debug("Updating subproject %s" % d)
1571 cmd = lparms + ['-p', d]
1572 p = SdkToolsPopen(cmd, cwd=root_dir)
1573 # Check to see whether an error was returned without a proper exit
1574 # code (this is the case for the 'no target set or target invalid'
1576 if p.returncode != 0 or p.output.startswith("Error: "):
1577 raise BuildException("Failed to update project at %s" % d, p.output)
1578 # Clean update dirs via ant
1580 logging.info("Cleaning subproject %s" % d)
1581 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1583 return (root_dir, srclibpaths)
1586 # Extend via globbing the paths from a field and return them as a map from
1587 # original path to resulting paths
1588 def getpaths_map(build_dir, globpaths):
1592 full_path = os.path.join(build_dir, p)
1593 full_path = os.path.normpath(full_path)
1594 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1596 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1600 # Extend via globbing the paths from a field and return them as a set
1601 def getpaths(build_dir, globpaths):
1602 paths_map = getpaths_map(build_dir, globpaths)
1604 for k, v in paths_map.items():
1611 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1617 self.path = os.path.join('stats', 'known_apks.txt')
1619 if os.path.isfile(self.path):
1620 with open(self.path, 'r', encoding='utf8') as f:
1622 t = line.rstrip().split(' ')
1624 self.apks[t[0]] = (t[1], None)
1626 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1627 self.changed = False
1629 def writeifchanged(self):
1630 if not self.changed:
1633 if not os.path.exists('stats'):
1637 for apk, app in self.apks.items():
1639 line = apk + ' ' + appid
1641 line += ' ' + added.strftime('%Y-%m-%d')
1644 with open(self.path, 'w', encoding='utf8') as f:
1645 for line in sorted(lst, key=natural_key):
1646 f.write(line + '\n')
1648 def recordapk(self, apk, app, default_date=None):
1650 Record an apk (if it's new, otherwise does nothing)
1651 Returns the date it was added as a datetime instance
1653 if apk not in self.apks:
1654 if default_date is None:
1655 default_date = datetime.utcnow()
1656 self.apks[apk] = (app, default_date)
1658 _, added = self.apks[apk]
1661 # Look up information - given the 'apkname', returns (app id, date added/None).
1662 # Or returns None for an unknown apk.
1663 def getapp(self, apkname):
1664 if apkname in self.apks:
1665 return self.apks[apkname]
1668 # Get the most recent 'num' apps added to the repo, as a list of package ids
1669 # with the most recent first.
1670 def getlatest(self, num):
1672 for apk, app in self.apks.items():
1676 if apps[appid] > added:
1680 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1681 lst = [app for app, _ in sortedapps]
1686 def get_file_extension(filename):
1687 """get the normalized file extension, can be blank string but never None"""
1688 if isinstance(filename, bytes):
1689 filename = filename.decode('utf-8')
1690 return os.path.splitext(filename)[1].lower()[1:]
1693 def isApkAndDebuggable(apkfile, config):
1694 """Returns True if the given file is an APK and is debuggable
1696 :param apkfile: full path to the apk to check"""
1698 if get_file_extension(apkfile) != 'apk':
1701 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1703 if p.returncode != 0:
1704 logging.critical("Failed to get apk manifest information")
1706 for line in p.output.splitlines():
1707 if 'android:debuggable' in line and not line.endswith('0x0'):
1714 self.returncode = None
1718 def SdkToolsPopen(commands, cwd=None, output=True):
1720 if cmd not in config:
1721 config[cmd] = find_sdk_tools_cmd(commands[0])
1722 abscmd = config[cmd]
1724 logging.critical("Could not find '%s' on your system" % cmd)
1727 test_aapt_version(config['aapt'])
1728 return FDroidPopen([abscmd] + commands[1:],
1729 cwd=cwd, output=output)
1732 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1734 Run a command and capture the possibly huge output as bytes.
1736 :param commands: command and argument list like in subprocess.Popen
1737 :param cwd: optionally specifies a working directory
1738 :param envs: a optional dictionary of environment variables and their values
1739 :returns: A PopenResult.
1744 set_FDroidPopen_env()
1746 process_env = env.copy()
1747 if envs is not None and len(envs) > 0:
1748 process_env.update(envs)
1751 cwd = os.path.normpath(cwd)
1752 logging.debug("Directory: %s" % cwd)
1753 logging.debug("> %s" % ' '.join(commands))
1755 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1756 result = PopenResult()
1759 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1760 stdout=subprocess.PIPE, stderr=stderr_param)
1761 except OSError as e:
1762 raise BuildException("OSError while trying to execute " +
1763 ' '.join(commands) + ': ' + str(e))
1765 if not stderr_to_stdout and options.verbose:
1766 stderr_queue = Queue()
1767 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1769 while not stderr_reader.eof():
1770 while not stderr_queue.empty():
1771 line = stderr_queue.get()
1772 sys.stderr.buffer.write(line)
1777 stdout_queue = Queue()
1778 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1781 # Check the queue for output (until there is no more to get)
1782 while not stdout_reader.eof():
1783 while not stdout_queue.empty():
1784 line = stdout_queue.get()
1785 if output and options.verbose:
1786 # Output directly to console
1787 sys.stderr.buffer.write(line)
1793 result.returncode = p.wait()
1794 result.output = buf.getvalue()
1799 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1801 Run a command and capture the possibly huge output as a str.
1803 :param commands: command and argument list like in subprocess.Popen
1804 :param cwd: optionally specifies a working directory
1805 :param envs: a optional dictionary of environment variables and their values
1806 :returns: A PopenResult.
1808 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1809 result.output = result.output.decode('utf-8', 'ignore')
1813 gradle_comment = re.compile(r'[ ]*//')
1814 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1815 gradle_line_matches = [
1816 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1817 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1818 re.compile(r'.*\.readLine\(.*'),
1822 def remove_signing_keys(build_dir):
1823 for root, dirs, files in os.walk(build_dir):
1824 if 'build.gradle' in files:
1825 path = os.path.join(root, 'build.gradle')
1827 with open(path, "r", encoding='utf8') as o:
1828 lines = o.readlines()
1834 with open(path, "w", encoding='utf8') as o:
1835 while i < len(lines):
1838 while line.endswith('\\\n'):
1839 line = line.rstrip('\\\n') + lines[i]
1842 if gradle_comment.match(line):
1847 opened += line.count('{')
1848 opened -= line.count('}')
1851 if gradle_signing_configs.match(line):
1856 if any(s.match(line) for s in gradle_line_matches):
1864 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1867 'project.properties',
1869 'default.properties',
1870 'ant.properties', ]:
1871 if propfile in files:
1872 path = os.path.join(root, propfile)
1874 with open(path, "r", encoding='iso-8859-1') as o:
1875 lines = o.readlines()
1879 with open(path, "w", encoding='iso-8859-1') as o:
1881 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1888 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1891 def set_FDroidPopen_env(build=None):
1893 set up the environment variables for the build environment
1895 There is only a weak standard, the variables used by gradle, so also set
1896 up the most commonly used environment variables for SDK and NDK. Also, if
1897 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1899 global env, orig_path
1903 orig_path = env['PATH']
1904 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1905 env[n] = config['sdk_path']
1906 for k, v in config['java_paths'].items():
1907 env['JAVA%s_HOME' % k] = v
1909 missinglocale = True
1910 for k, v in env.items():
1911 if k == 'LANG' and v != 'C':
1912 missinglocale = False
1914 missinglocale = False
1916 env['LANG'] = 'en_US.UTF-8'
1918 if build is not None:
1919 path = build.ndk_path()
1920 paths = orig_path.split(os.pathsep)
1921 if path not in paths:
1922 paths = [path] + paths
1923 env['PATH'] = os.pathsep.join(paths)
1924 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1925 env[n] = build.ndk_path()
1928 def replace_build_vars(cmd, build):
1929 cmd = cmd.replace('$$COMMIT$$', build.commit)
1930 cmd = cmd.replace('$$VERSION$$', build.versionName)
1931 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1935 def replace_config_vars(cmd, build):
1936 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1937 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1938 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1939 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1940 if build is not None:
1941 cmd = replace_build_vars(cmd, build)
1945 def place_srclib(root_dir, number, libpath):
1948 relpath = os.path.relpath(libpath, root_dir)
1949 proppath = os.path.join(root_dir, 'project.properties')
1952 if os.path.isfile(proppath):
1953 with open(proppath, "r", encoding='iso-8859-1') as o:
1954 lines = o.readlines()
1956 with open(proppath, "w", encoding='iso-8859-1') as o:
1959 if line.startswith('android.library.reference.%d=' % number):
1960 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1965 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1968 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1971 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1972 """Verify that two apks are the same
1974 One of the inputs is signed, the other is unsigned. The signature metadata
1975 is transferred from the signed to the unsigned apk, and then jarsigner is
1976 used to verify that the signature from the signed apk is also varlid for
1977 the unsigned one. If the APK given as unsigned actually does have a
1978 signature, it will be stripped out and ignored.
1980 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1981 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1982 into AndroidManifest.xml, but that makes the build not reproducible. So
1983 instead they are included as separate files in the APK's META-INF/ folder.
1984 If those files exist in the signed APK, they will be part of the signature
1985 and need to also be included in the unsigned APK for it to validate.
1987 :param signed_apk: Path to a signed apk file
1988 :param unsigned_apk: Path to an unsigned apk file expected to match it
1989 :param tmp_dir: Path to directory for temporary files
1990 :returns: None if the verification is successful, otherwise a string
1991 describing what went wrong.
1994 signed = ZipFile(signed_apk, 'r')
1995 meta_inf_files = ['META-INF/MANIFEST.MF']
1996 for f in signed.namelist():
1997 if apk_sigfile.match(f) \
1998 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
1999 meta_inf_files.append(f)
2000 if len(meta_inf_files) < 3:
2001 return "Signature files missing from {0}".format(signed_apk)
2003 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2004 unsigned = ZipFile(unsigned_apk, 'r')
2005 # only read the signature from the signed APK, everything else from unsigned
2006 with ZipFile(tmp_apk, 'w') as tmp:
2007 for filename in meta_inf_files:
2008 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2009 for info in unsigned.infolist():
2010 if info.filename in meta_inf_files:
2011 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2013 if info.filename in tmp.namelist():
2014 return "duplicate filename found: " + info.filename
2015 tmp.writestr(info, unsigned.read(info.filename))
2019 verified = verify_apk_signature(tmp_apk)
2022 logging.info("...NOT verified - {0}".format(tmp_apk))
2023 return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2025 logging.info("...successfully verified")
2029 def verify_apk_signature(apk, jar=False):
2030 """verify the signature on an APK
2032 Try to use apksigner whenever possible since jarsigner is very
2033 shitty: unsigned APKs pass as "verified"! So this has to turn on
2034 -strict then check for result 4.
2036 You can set :param: jar to True if you want to use this method
2037 to verify jar signatures.
2039 if set_command_in_config('apksigner'):
2040 args = [config['apksigner'], 'verify']
2042 args += ['--min-sdk-version=1']
2043 return subprocess.call(args + [apk]) == 0
2045 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2046 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2049 apk_badchars = re.compile('''[/ :;'"]''')
2052 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2055 Returns None if the apk content is the same (apart from the signing key),
2056 otherwise a string describing what's different, or what went wrong when
2057 trying to do the comparison.
2063 absapk1 = os.path.abspath(apk1)
2064 absapk2 = os.path.abspath(apk2)
2066 if set_command_in_config('diffoscope'):
2067 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2068 htmlfile = logfilename + '.diffoscope.html'
2069 textfile = logfilename + '.diffoscope.txt'
2070 if subprocess.call([config['diffoscope'],
2071 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2072 '--html', htmlfile, '--text', textfile,
2073 absapk1, absapk2]) != 0:
2074 return("Failed to unpack " + apk1)
2076 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2077 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2078 for d in [apk1dir, apk2dir]:
2079 if os.path.exists(d):
2082 os.mkdir(os.path.join(d, 'jar-xf'))
2084 if subprocess.call(['jar', 'xf',
2085 os.path.abspath(apk1)],
2086 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2087 return("Failed to unpack " + apk1)
2088 if subprocess.call(['jar', 'xf',
2089 os.path.abspath(apk2)],
2090 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2091 return("Failed to unpack " + apk2)
2093 if set_command_in_config('apktool'):
2094 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2096 return("Failed to unpack " + apk1)
2097 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2099 return("Failed to unpack " + apk2)
2101 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2102 lines = p.output.splitlines()
2103 if len(lines) != 1 or 'META-INF' not in lines[0]:
2104 meld = find_command('meld')
2105 if meld is not None:
2106 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2107 return("Unexpected diff output - " + p.output)
2109 # since everything verifies, delete the comparison to keep cruft down
2110 shutil.rmtree(apk1dir)
2111 shutil.rmtree(apk2dir)
2113 # If we get here, it seems like they're the same!
2117 def set_command_in_config(command):
2118 '''Try to find specified command in the path, if it hasn't been
2119 manually set in config.py. If found, it is added to the config
2120 dict. The return value says whether the command is available.
2123 if command in config:
2126 tmp = find_command(command)
2128 config[command] = tmp
2133 def find_command(command):
2134 '''find the full path of a command, or None if it can't be found in the PATH'''
2137 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2139 fpath, fname = os.path.split(command)
2144 for path in os.environ["PATH"].split(os.pathsep):
2145 path = path.strip('"')
2146 exe_file = os.path.join(path, command)
2147 if is_exe(exe_file):
2154 '''generate a random password for when generating keys'''
2155 h = hashlib.sha256()
2156 h.update(os.urandom(16)) # salt
2157 h.update(socket.getfqdn().encode('utf-8'))
2158 passwd = base64.b64encode(h.digest()).strip()
2159 return passwd.decode('utf-8')
2162 def genkeystore(localconfig):
2164 Generate a new key with password provided in :param localconfig and add it to new keystore
2165 :return: hexed public key, public key fingerprint
2167 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2168 keystoredir = os.path.dirname(localconfig['keystore'])
2169 if keystoredir is None or keystoredir == '':
2170 keystoredir = os.path.join(os.getcwd(), keystoredir)
2171 if not os.path.exists(keystoredir):
2172 os.makedirs(keystoredir, mode=0o700)
2175 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2176 'FDROID_KEY_PASS': localconfig['keypass'],
2178 p = FDroidPopen([config['keytool'], '-genkey',
2179 '-keystore', localconfig['keystore'],
2180 '-alias', localconfig['repo_keyalias'],
2181 '-keyalg', 'RSA', '-keysize', '4096',
2182 '-sigalg', 'SHA256withRSA',
2183 '-validity', '10000',
2184 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2185 '-keypass:env', 'FDROID_KEY_PASS',
2186 '-dname', localconfig['keydname']], envs=env_vars)
2187 if p.returncode != 0:
2188 raise BuildException("Failed to generate key", p.output)
2189 os.chmod(localconfig['keystore'], 0o0600)
2190 if not options.quiet:
2191 # now show the lovely key that was just generated
2192 p = FDroidPopen([config['keytool'], '-list', '-v',
2193 '-keystore', localconfig['keystore'],
2194 '-alias', localconfig['repo_keyalias'],
2195 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2196 logging.info(p.output.strip() + '\n\n')
2197 # get the public key
2198 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2199 '-keystore', localconfig['keystore'],
2200 '-alias', localconfig['repo_keyalias'],
2201 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2202 + config['smartcardoptions'],
2203 envs=env_vars, output=False, stderr_to_stdout=False)
2204 if p.returncode != 0 or len(p.output) < 20:
2205 raise BuildException("Failed to get public key", p.output)
2207 fingerprint = get_cert_fingerprint(pubkey)
2208 return hexlify(pubkey), fingerprint
2211 def get_cert_fingerprint(pubkey):
2213 Generate a certificate fingerprint the same way keytool does it
2214 (but with slightly different formatting)
2216 digest = hashlib.sha256(pubkey).digest()
2217 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2218 return " ".join(ret)
2221 def get_certificate(certificate_file):
2223 Extracts a certificate from the given file.
2224 :param certificate_file: file bytes (as string) representing the certificate
2225 :return: A binary representation of the certificate's public key, or None in case of error
2227 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2228 if content.getComponentByName('contentType') != rfc2315.signedData:
2230 content = decoder.decode(content.getComponentByName('content'),
2231 asn1Spec=rfc2315.SignedData())[0]
2233 certificates = content.getComponentByName('certificates')
2234 cert = certificates[0].getComponentByName('certificate')
2236 logging.error("Certificates not found.")
2238 return encoder.encode(cert)
2241 def write_to_config(thisconfig, key, value=None, config_file=None):
2242 '''write a key/value to the local config.py
2244 NOTE: only supports writing string variables.
2246 :param thisconfig: config dictionary
2247 :param key: variable name in config.py to be overwritten/added
2248 :param value: optional value to be written, instead of fetched
2249 from 'thisconfig' dictionary.
2252 origkey = key + '_orig'
2253 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2254 cfg = config_file if config_file else 'config.py'
2257 with open(cfg, 'r', encoding="utf-8") as f:
2258 lines = f.readlines()
2260 # make sure the file ends with a carraige return
2262 if not lines[-1].endswith('\n'):
2265 # regex for finding and replacing python string variable
2266 # definitions/initializations
2267 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2268 repl = key + ' = "' + value + '"'
2269 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2270 repl2 = key + " = '" + value + "'"
2272 # If we replaced this line once, we make sure won't be a
2273 # second instance of this line for this key in the document.
2276 with open(cfg, 'w', encoding="utf-8") as f:
2278 if pattern.match(line) or pattern2.match(line):
2280 line = pattern.sub(repl, line)
2281 line = pattern2.sub(repl2, line)
2292 def parse_xml(path):
2293 return XMLElementTree.parse(path).getroot()
2296 def string_is_integer(string):
2304 def get_per_app_repos():
2305 '''per-app repos are dirs named with the packageName of a single app'''
2307 # Android packageNames are Java packages, they may contain uppercase or
2308 # lowercase letters ('A' through 'Z'), numbers, and underscores
2309 # ('_'). However, individual package name parts may only start with
2310 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2311 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2314 for root, dirs, files in os.walk(os.getcwd()):
2316 print('checking', root, 'for', d)
2317 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2318 # standard parts of an fdroid repo, so never packageNames
2321 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2327 def is_repo_file(filename):
2328 '''Whether the file in a repo is a build product to be delivered to users'''
2329 if isinstance(filename, str):
2330 filename = filename.encode('utf-8', errors="surrogateescape")
2331 return os.path.isfile(filename) \
2332 and not filename.endswith(b'.asc') \
2333 and not filename.endswith(b'.sig') \
2334 and os.path.basename(filename) not in [
2336 b'index_unsigned.jar',