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': [],
100 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
101 'repo_name': "My First FDroid Repo Demo",
102 'repo_icon': "fdroid-icon.png",
103 'repo_description': '''
104 This is a repository of apps to be used with FDroid. Applications in this
105 repository are either official binaries built by the original application
106 developers, or are binaries built from source by the admin of f-droid.org
107 using the tools on https://gitlab.com/u/fdroid.
113 def setup_global_opts(parser):
114 parser.add_argument("-v", "--verbose", action="store_true", default=False,
115 help="Spew out even more information than normal")
116 parser.add_argument("-q", "--quiet", action="store_true", default=False,
117 help="Restrict output to warnings and errors")
120 def fill_config_defaults(thisconfig):
121 for k, v in default_config.items():
122 if k not in thisconfig:
125 # Expand paths (~users and $vars)
126 def expand_path(path):
130 path = os.path.expanduser(path)
131 path = os.path.expandvars(path)
136 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
141 thisconfig[k + '_orig'] = v
143 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
144 if thisconfig['java_paths'] is None:
145 thisconfig['java_paths'] = dict()
147 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
148 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
149 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
150 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
151 if os.getenv('JAVA_HOME') is not None:
152 pathlist.append(os.getenv('JAVA_HOME'))
153 if os.getenv('PROGRAMFILES') is not None:
154 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
155 for d in sorted(pathlist):
156 if os.path.islink(d):
158 j = os.path.basename(d)
159 # the last one found will be the canonical one, so order appropriately
161 r'^1\.([6-9])\.0\.jdk$', # OSX
162 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
163 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
164 r'^jdk([6-9])-openjdk$', # Arch
165 r'^java-([6-9])-openjdk$', # Arch
166 r'^java-([6-9])-jdk$', # Arch (oracle)
167 r'^java-1\.([6-9])\.0-.*$', # RedHat
168 r'^java-([6-9])-oracle$', # Debian WebUpd8
169 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
170 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
172 m = re.match(regex, j)
175 for p in [d, os.path.join(d, 'Contents', 'Home')]:
176 if os.path.exists(os.path.join(p, 'bin', 'javac')):
177 thisconfig['java_paths'][m.group(1)] = p
179 for java_version in ('7', '8', '9'):
180 if java_version not in thisconfig['java_paths']:
182 java_home = thisconfig['java_paths'][java_version]
183 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
184 if os.path.exists(jarsigner):
185 thisconfig['jarsigner'] = jarsigner
186 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
187 break # Java7 is preferred, so quit if found
189 for k in ['ndk_paths', 'java_paths']:
195 thisconfig[k][k2] = exp
196 thisconfig[k][k2 + '_orig'] = v
199 def regsub_file(pattern, repl, path):
200 with open(path, 'rb') as f:
202 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
203 with open(path, 'wb') as f:
207 def read_config(opts, config_file='config.py'):
208 """Read the repository config
210 The config is read from config_file, which is in the current
211 directory when any of the repo management commands are used. If
212 there is a local metadata file in the git repo, then config.py is
213 not required, just use defaults.
216 global config, options
218 if config is not None:
225 if os.path.isfile(config_file):
226 logging.debug("Reading %s" % config_file)
227 with io.open(config_file, "rb") as f:
228 code = compile(f.read(), config_file, 'exec')
229 exec(code, None, config)
230 elif len(get_local_metadata_files()) == 0:
231 logging.critical("Missing config file - is this a repo directory?")
234 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
236 if not type(config[k]) in (str, list, tuple):
237 logging.warn('"' + k + '" will be in random order!'
238 + ' Use () or [] brackets if order is important!')
240 # smartcardoptions must be a list since its command line args for Popen
241 if 'smartcardoptions' in config:
242 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
243 elif 'keystore' in config and config['keystore'] == 'NONE':
244 # keystore='NONE' means use smartcard, these are required defaults
245 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
246 'SunPKCS11-OpenSC', '-providerClass',
247 'sun.security.pkcs11.SunPKCS11',
248 '-providerArg', 'opensc-fdroid.cfg']
250 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
251 st = os.stat(config_file)
252 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
253 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
255 fill_config_defaults(config)
257 for k in ["repo_description", "archive_description"]:
259 config[k] = clean_description(config[k])
261 if 'serverwebroot' in config:
262 if isinstance(config['serverwebroot'], str):
263 roots = [config['serverwebroot']]
264 elif all(isinstance(item, str) for item in config['serverwebroot']):
265 roots = config['serverwebroot']
267 raise TypeError('only accepts strings, lists, and tuples')
269 for rootstr in roots:
270 # since this is used with rsync, where trailing slashes have
271 # meaning, ensure there is always a trailing slash
272 if rootstr[-1] != '/':
274 rootlist.append(rootstr.replace('//', '/'))
275 config['serverwebroot'] = rootlist
277 if 'servergitmirrors' in config:
278 if isinstance(config['servergitmirrors'], str):
279 roots = [config['servergitmirrors']]
280 elif all(isinstance(item, str) for item in config['servergitmirrors']):
281 roots = config['servergitmirrors']
283 raise TypeError('only accepts strings, lists, and tuples')
284 config['servergitmirrors'] = roots
289 def find_sdk_tools_cmd(cmd):
290 '''find a working path to a tool from the Android SDK'''
293 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
294 # try to find a working path to this command, in all the recent possible paths
295 if 'build_tools' in config:
296 build_tools = os.path.join(config['sdk_path'], 'build-tools')
297 # if 'build_tools' was manually set and exists, check only that one
298 configed_build_tools = os.path.join(build_tools, config['build_tools'])
299 if os.path.exists(configed_build_tools):
300 tooldirs.append(configed_build_tools)
302 # no configed version, so hunt known paths for it
303 for f in sorted(os.listdir(build_tools), reverse=True):
304 if os.path.isdir(os.path.join(build_tools, f)):
305 tooldirs.append(os.path.join(build_tools, f))
306 tooldirs.append(build_tools)
307 sdk_tools = os.path.join(config['sdk_path'], 'tools')
308 if os.path.exists(sdk_tools):
309 tooldirs.append(sdk_tools)
310 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
311 if os.path.exists(sdk_platform_tools):
312 tooldirs.append(sdk_platform_tools)
313 tooldirs.append('/usr/bin')
315 path = os.path.join(d, cmd)
316 if os.path.isfile(path):
318 test_aapt_version(path)
320 # did not find the command, exit with error message
321 ensure_build_tools_exists(config)
324 def test_aapt_version(aapt):
325 '''Check whether the version of aapt is new enough'''
326 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
327 if output is None or output == '':
328 logging.error(aapt + ' failed to execute!')
330 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
335 # the Debian package has the version string like "v0.2-23.0.2"
336 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
337 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
339 logging.warning('Unknown version of aapt, might cause problems: ' + output)
342 def test_sdk_exists(thisconfig):
343 if 'sdk_path' not in thisconfig:
344 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
345 test_aapt_version(thisconfig['aapt'])
348 logging.error("'sdk_path' not set in config.py!")
350 if thisconfig['sdk_path'] == default_config['sdk_path']:
351 logging.error('No Android SDK found!')
352 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
353 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
355 if not os.path.exists(thisconfig['sdk_path']):
356 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
358 if not os.path.isdir(thisconfig['sdk_path']):
359 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
361 for d in ['build-tools', 'platform-tools', 'tools']:
362 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
363 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
364 thisconfig['sdk_path'], d))
369 def ensure_build_tools_exists(thisconfig):
370 if not test_sdk_exists(thisconfig):
372 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
373 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
374 if not os.path.isdir(versioned_build_tools):
375 logging.critical('Android Build Tools path "'
376 + versioned_build_tools + '" does not exist!')
380 def get_local_metadata_files():
381 '''get any metadata files local to an app's source repo
383 This tries to ignore anything that does not count as app metdata,
384 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
387 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
390 def read_pkg_args(args, allow_vercodes=False):
392 Given the arguments in the form of multiple appid:[vc] strings, this returns
393 a dictionary with the set of vercodes specified for each package.
401 if allow_vercodes and ':' in p:
402 package, vercode = p.split(':')
404 package, vercode = p, None
405 if package not in vercodes:
406 vercodes[package] = [vercode] if vercode else []
408 elif vercode and vercode not in vercodes[package]:
409 vercodes[package] += [vercode] if vercode else []
414 def read_app_args(args, allapps, allow_vercodes=False):
416 On top of what read_pkg_args does, this returns the whole app metadata, but
417 limiting the builds list to the builds matching the vercodes specified.
420 vercodes = read_pkg_args(args, allow_vercodes)
426 for appid, app in allapps.items():
427 if appid in vercodes:
430 if len(apps) != len(vercodes):
433 logging.critical("No such package: %s" % p)
434 raise FDroidException("Found invalid app ids in arguments")
436 raise FDroidException("No packages specified")
439 for appid, app in apps.items():
443 app.builds = [b for b in app.builds if b.versionCode in vc]
444 if len(app.builds) != len(vercodes[appid]):
446 allvcs = [b.versionCode for b in app.builds]
447 for v in vercodes[appid]:
449 logging.critical("No such vercode %s for app %s" % (v, appid))
452 raise FDroidException("Found invalid vercodes for some apps")
457 def get_extension(filename):
458 base, ext = os.path.splitext(filename)
461 return base, ext.lower()[1:]
464 def has_extension(filename, ext):
465 _, f_ext = get_extension(filename)
469 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
472 def clean_description(description):
473 'Remove unneeded newlines and spaces from a block of description text'
475 # this is split up by paragraph to make removing the newlines easier
476 for paragraph in re.split(r'\n\n', description):
477 paragraph = re.sub('\r', '', paragraph)
478 paragraph = re.sub('\n', ' ', paragraph)
479 paragraph = re.sub(' {2,}', ' ', paragraph)
480 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
481 returnstring += paragraph + '\n\n'
482 return returnstring.rstrip('\n')
485 def publishednameinfo(filename):
486 filename = os.path.basename(filename)
487 m = publish_name_regex.match(filename)
489 result = (m.group(1), m.group(2))
490 except AttributeError:
491 raise FDroidException("Invalid name for published file: %s" % filename)
495 def get_release_filename(app, build):
497 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
499 return "%s_%s.apk" % (app.id, build.versionCode)
502 def get_toolsversion_logname(app, build):
503 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
506 def getsrcname(app, build):
507 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
519 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
522 def get_build_dir(app):
523 '''get the dir that this app will be built in'''
525 if app.RepoType == 'srclib':
526 return os.path.join('build', 'srclib', app.Repo)
528 return os.path.join('build', app.id)
532 '''checkout code from VCS and return instance of vcs and the build dir'''
533 build_dir = get_build_dir(app)
535 # Set up vcs interface and make sure we have the latest code...
536 logging.debug("Getting {0} vcs interface for {1}"
537 .format(app.RepoType, app.Repo))
538 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
542 vcs = getvcs(app.RepoType, remote, build_dir)
544 return vcs, build_dir
547 def getvcs(vcstype, remote, local):
549 return vcs_git(remote, local)
550 if vcstype == 'git-svn':
551 return vcs_gitsvn(remote, local)
553 return vcs_hg(remote, local)
555 return vcs_bzr(remote, local)
556 if vcstype == 'srclib':
557 if local != os.path.join('build', 'srclib', remote):
558 raise VCSException("Error: srclib paths are hard-coded!")
559 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
561 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
562 raise VCSException("Invalid vcs type " + vcstype)
565 def getsrclibvcs(name):
566 if name not in fdroidserver.metadata.srclibs:
567 raise VCSException("Missing srclib " + name)
568 return fdroidserver.metadata.srclibs[name]['Repo Type']
573 def __init__(self, remote, local):
575 # svn, git-svn and bzr may require auth
577 if self.repotype() in ('git-svn', 'bzr'):
579 if self.repotype == 'git-svn':
580 raise VCSException("Authentication is not supported for git-svn")
581 self.username, remote = remote.split('@')
582 if ':' not in self.username:
583 raise VCSException("Password required with username")
584 self.username, self.password = self.username.split(':')
588 self.clone_failed = False
589 self.refreshed = False
595 # Take the local repository to a clean version of the given revision, which
596 # is specificed in the VCS's native format. Beforehand, the repository can
597 # be dirty, or even non-existent. If the repository does already exist
598 # locally, it will be updated from the origin, but only once in the
599 # lifetime of the vcs object.
600 # None is acceptable for 'rev' if you know you are cloning a clean copy of
601 # the repo - otherwise it must specify a valid revision.
602 def gotorevision(self, rev, refresh=True):
604 if self.clone_failed:
605 raise VCSException("Downloading the repository already failed once, not trying again.")
607 # The .fdroidvcs-id file for a repo tells us what VCS type
608 # and remote that directory was created from, allowing us to drop it
609 # automatically if either of those things changes.
610 fdpath = os.path.join(self.local, '..',
611 '.fdroidvcs-' + os.path.basename(self.local))
612 fdpath = os.path.normpath(fdpath)
613 cdata = self.repotype() + ' ' + self.remote
616 if os.path.exists(self.local):
617 if os.path.exists(fdpath):
618 with open(fdpath, 'r') as f:
619 fsdata = f.read().strip()
624 logging.info("Repository details for %s changed - deleting" % (
628 logging.info("Repository details for %s missing - deleting" % (
631 shutil.rmtree(self.local)
635 self.refreshed = True
638 self.gotorevisionx(rev)
639 except FDroidException as e:
642 # If necessary, write the .fdroidvcs file.
643 if writeback and not self.clone_failed:
644 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
645 with open(fdpath, 'w+') as f:
651 # Derived classes need to implement this. It's called once basic checking
652 # has been performend.
653 def gotorevisionx(self, rev):
654 raise VCSException("This VCS type doesn't define gotorevisionx")
656 # Initialise and update submodules
657 def initsubmodules(self):
658 raise VCSException('Submodules not supported for this vcs type')
660 # Get a list of all known tags
662 if not self._gettags:
663 raise VCSException('gettags not supported for this vcs type')
665 for tag in self._gettags():
666 if re.match('[-A-Za-z0-9_. /]+$', tag):
670 # Get a list of all the known tags, sorted from newest to oldest
671 def latesttags(self):
672 raise VCSException('latesttags not supported for this vcs type')
674 # Get current commit reference (hash, revision, etc)
676 raise VCSException('getref not supported for this vcs type')
678 # Returns the srclib (name, path) used in setting up the current
689 # If the local directory exists, but is somehow not a git repository, git
690 # will traverse up the directory tree until it finds one that is (i.e.
691 # fdroidserver) and then we'll proceed to destroy it! This is called as
694 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
695 result = p.output.rstrip()
696 if not result.endswith(self.local):
697 raise VCSException('Repository mismatch')
699 def gotorevisionx(self, rev):
700 if not os.path.exists(self.local):
702 p = FDroidPopen(['git', 'clone', self.remote, self.local])
703 if p.returncode != 0:
704 self.clone_failed = True
705 raise VCSException("Git clone failed", p.output)
709 # Discard any working tree changes
710 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
711 'git', 'reset', '--hard'], cwd=self.local, output=False)
712 if p.returncode != 0:
713 raise VCSException("Git reset failed", p.output)
714 # Remove untracked files now, in case they're tracked in the target
715 # revision (it happens!)
716 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
717 'git', 'clean', '-dffx'], cwd=self.local, output=False)
718 if p.returncode != 0:
719 raise VCSException("Git clean failed", p.output)
720 if not self.refreshed:
721 # Get latest commits and tags from remote
722 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
723 if p.returncode != 0:
724 raise VCSException("Git fetch failed", p.output)
725 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
726 if p.returncode != 0:
727 raise VCSException("Git fetch failed", p.output)
728 # Recreate origin/HEAD as git clone would do it, in case it disappeared
729 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
730 if p.returncode != 0:
731 lines = p.output.splitlines()
732 if 'Multiple remote HEAD branches' not in lines[0]:
733 raise VCSException("Git remote set-head failed", p.output)
734 branch = lines[1].split(' ')[-1]
735 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
736 if p2.returncode != 0:
737 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
738 self.refreshed = True
739 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
740 # a github repo. Most of the time this is the same as origin/master.
741 rev = rev or 'origin/HEAD'
742 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
743 if p.returncode != 0:
744 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
745 # Get rid of any uncontrolled files left behind
746 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
747 if p.returncode != 0:
748 raise VCSException("Git clean failed", p.output)
750 def initsubmodules(self):
752 submfile = os.path.join(self.local, '.gitmodules')
753 if not os.path.isfile(submfile):
754 raise VCSException("No git submodules available")
756 # fix submodules not accessible without an account and public key auth
757 with open(submfile, 'r') as f:
758 lines = f.readlines()
759 with open(submfile, 'w') as f:
761 if 'git@github.com' in line:
762 line = line.replace('git@github.com:', 'https://github.com/')
763 if 'git@gitlab.com' in line:
764 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
767 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
768 if p.returncode != 0:
769 raise VCSException("Git submodule sync failed", p.output)
770 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
771 if p.returncode != 0:
772 raise VCSException("Git submodule update failed", p.output)
776 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
777 return p.output.splitlines()
779 tag_format = re.compile(r'tag: ([^),]*)')
781 def latesttags(self):
783 p = FDroidPopen(['git', 'log', '--tags',
784 '--simplify-by-decoration', '--pretty=format:%d'],
785 cwd=self.local, output=False)
787 for line in p.output.splitlines():
788 for tag in self.tag_format.findall(line):
793 class vcs_gitsvn(vcs):
798 # If the local directory exists, but is somehow not a git repository, git
799 # will traverse up the directory tree until it finds one that is (i.e.
800 # fdroidserver) and then we'll proceed to destory it! This is called as
803 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
804 result = p.output.rstrip()
805 if not result.endswith(self.local):
806 raise VCSException('Repository mismatch')
808 def gotorevisionx(self, rev):
809 if not os.path.exists(self.local):
811 gitsvn_args = ['git', 'svn', 'clone']
812 if ';' in self.remote:
813 remote_split = self.remote.split(';')
814 for i in remote_split[1:]:
815 if i.startswith('trunk='):
816 gitsvn_args.extend(['-T', i[6:]])
817 elif i.startswith('tags='):
818 gitsvn_args.extend(['-t', i[5:]])
819 elif i.startswith('branches='):
820 gitsvn_args.extend(['-b', i[9:]])
821 gitsvn_args.extend([remote_split[0], self.local])
822 p = FDroidPopen(gitsvn_args, output=False)
823 if p.returncode != 0:
824 self.clone_failed = True
825 raise VCSException("Git svn clone failed", p.output)
827 gitsvn_args.extend([self.remote, self.local])
828 p = FDroidPopen(gitsvn_args, output=False)
829 if p.returncode != 0:
830 self.clone_failed = True
831 raise VCSException("Git svn clone failed", p.output)
835 # Discard any working tree changes
836 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
837 if p.returncode != 0:
838 raise VCSException("Git reset failed", p.output)
839 # Remove untracked files now, in case they're tracked in the target
840 # revision (it happens!)
841 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("Git clean failed", p.output)
844 if not self.refreshed:
845 # Get new commits, branches and tags from repo
846 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
847 if p.returncode != 0:
848 raise VCSException("Git svn fetch failed")
849 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
850 if p.returncode != 0:
851 raise VCSException("Git svn rebase failed", p.output)
852 self.refreshed = True
854 rev = rev or 'master'
856 nospaces_rev = rev.replace(' ', '%20')
857 # Try finding a svn tag
858 for treeish in ['origin/', '']:
859 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
860 if p.returncode == 0:
862 if p.returncode != 0:
863 # No tag found, normal svn rev translation
864 # Translate svn rev into git format
865 rev_split = rev.split('/')
868 for treeish in ['origin/', '']:
869 if len(rev_split) > 1:
870 treeish += rev_split[0]
871 svn_rev = rev_split[1]
874 # if no branch is specified, then assume trunk (i.e. 'master' branch):
878 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
880 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
881 git_rev = p.output.rstrip()
883 if p.returncode == 0 and git_rev:
886 if p.returncode != 0 or not git_rev:
887 # Try a plain git checkout as a last resort
888 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
889 if p.returncode != 0:
890 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
892 # Check out the git rev equivalent to the svn rev
893 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
894 if p.returncode != 0:
895 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
897 # Get rid of any uncontrolled files left behind
898 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
899 if p.returncode != 0:
900 raise VCSException("Git clean failed", p.output)
904 for treeish in ['origin/', '']:
905 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
911 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
912 if p.returncode != 0:
914 return p.output.strip()
922 def gotorevisionx(self, rev):
923 if not os.path.exists(self.local):
924 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
925 if p.returncode != 0:
926 self.clone_failed = True
927 raise VCSException("Hg clone failed", p.output)
929 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
930 if p.returncode != 0:
931 raise VCSException("Hg status failed", p.output)
932 for line in p.output.splitlines():
933 if not line.startswith('? '):
934 raise VCSException("Unexpected output from hg status -uS: " + line)
935 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
936 if not self.refreshed:
937 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
938 if p.returncode != 0:
939 raise VCSException("Hg pull failed", p.output)
940 self.refreshed = True
942 rev = rev or 'default'
945 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
946 if p.returncode != 0:
947 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
948 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
949 # Also delete untracked files, we have to enable purge extension for that:
950 if "'purge' is provided by the following extension" in p.output:
951 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
952 myfile.write("\n[extensions]\nhgext.purge=\n")
953 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
954 if p.returncode != 0:
955 raise VCSException("HG purge failed", p.output)
956 elif p.returncode != 0:
957 raise VCSException("HG purge failed", p.output)
960 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
961 return p.output.splitlines()[1:]
969 def gotorevisionx(self, rev):
970 if not os.path.exists(self.local):
971 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
972 if p.returncode != 0:
973 self.clone_failed = True
974 raise VCSException("Bzr branch failed", p.output)
976 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
977 if p.returncode != 0:
978 raise VCSException("Bzr revert failed", p.output)
979 if not self.refreshed:
980 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
981 if p.returncode != 0:
982 raise VCSException("Bzr update failed", p.output)
983 self.refreshed = True
985 revargs = list(['-r', rev] if rev else [])
986 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
987 if p.returncode != 0:
988 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
991 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
992 return [tag.split(' ')[0].strip() for tag in
993 p.output.splitlines()]
996 def unescape_string(string):
999 if string[0] == '"' and string[-1] == '"':
1002 return string.replace("\\'", "'")
1005 def retrieve_string(app_dir, string, xmlfiles=None):
1007 if not string.startswith('@string/'):
1008 return unescape_string(string)
1010 if xmlfiles is None:
1013 os.path.join(app_dir, 'res'),
1014 os.path.join(app_dir, 'src', 'main', 'res'),
1016 for r, d, f in os.walk(res_dir):
1017 if os.path.basename(r) == 'values':
1018 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1020 name = string[len('@string/'):]
1022 def element_content(element):
1023 if element.text is None:
1025 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1026 return s.decode('utf-8').strip()
1028 for path in xmlfiles:
1029 if not os.path.isfile(path):
1031 xml = parse_xml(path)
1032 element = xml.find('string[@name="' + name + '"]')
1033 if element is not None:
1034 content = element_content(element)
1035 return retrieve_string(app_dir, content, xmlfiles)
1040 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1041 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1044 def manifest_paths(app_dir, flavours):
1045 '''Return list of existing files that will be used to find the highest vercode'''
1047 possible_manifests = \
1048 [os.path.join(app_dir, 'AndroidManifest.xml'),
1049 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1050 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1051 os.path.join(app_dir, 'build.gradle')]
1053 for flavour in flavours:
1054 if flavour == 'yes':
1056 possible_manifests.append(
1057 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1059 return [path for path in possible_manifests if os.path.isfile(path)]
1062 def fetch_real_name(app_dir, flavours):
1063 '''Retrieve the package name. Returns the name, or None if not found.'''
1064 for path in manifest_paths(app_dir, flavours):
1065 if not has_extension(path, 'xml') or not os.path.isfile(path):
1067 logging.debug("fetch_real_name: Checking manifest at " + path)
1068 xml = parse_xml(path)
1069 app = xml.find('application')
1072 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1074 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1075 result = retrieve_string_singleline(app_dir, label)
1077 result = result.strip()
1082 def get_library_references(root_dir):
1084 proppath = os.path.join(root_dir, 'project.properties')
1085 if not os.path.isfile(proppath):
1087 with open(proppath, 'r', encoding='iso-8859-1') as f:
1089 if not line.startswith('android.library.reference.'):
1091 path = line.split('=')[1].strip()
1092 relpath = os.path.join(root_dir, path)
1093 if not os.path.isdir(relpath):
1095 logging.debug("Found subproject at %s" % path)
1096 libraries.append(path)
1100 def ant_subprojects(root_dir):
1101 subprojects = get_library_references(root_dir)
1102 for subpath in subprojects:
1103 subrelpath = os.path.join(root_dir, subpath)
1104 for p in get_library_references(subrelpath):
1105 relp = os.path.normpath(os.path.join(subpath, p))
1106 if relp not in subprojects:
1107 subprojects.insert(0, relp)
1111 def remove_debuggable_flags(root_dir):
1112 # Remove forced debuggable flags
1113 logging.debug("Removing debuggable flags from %s" % root_dir)
1114 for root, dirs, files in os.walk(root_dir):
1115 if 'AndroidManifest.xml' in files:
1116 regsub_file(r'android:debuggable="[^"]*"',
1118 os.path.join(root, 'AndroidManifest.xml'))
1121 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1122 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1123 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1126 def app_matches_packagename(app, package):
1129 appid = app.UpdateCheckName or app.id
1130 if appid is None or appid == "Ignore":
1132 return appid == package
1135 def parse_androidmanifests(paths, app):
1137 Extract some information from the AndroidManifest.xml at the given path.
1138 Returns (version, vercode, package), any or all of which might be None.
1139 All values returned are strings.
1142 ignoreversions = app.UpdateCheckIgnore
1143 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1146 return (None, None, None)
1154 if not os.path.isfile(path):
1157 logging.debug("Parsing manifest at {0}".format(path))
1162 if has_extension(path, 'gradle'):
1163 with open(path, 'r') as f:
1165 if gradle_comment.match(line):
1167 # Grab first occurence of each to avoid running into
1168 # alternative flavours and builds.
1170 matches = psearch_g(line)
1172 s = matches.group(2)
1173 if app_matches_packagename(app, s):
1176 matches = vnsearch_g(line)
1178 version = matches.group(2)
1180 matches = vcsearch_g(line)
1182 vercode = matches.group(1)
1185 xml = parse_xml(path)
1186 if "package" in xml.attrib:
1187 s = xml.attrib["package"]
1188 if app_matches_packagename(app, s):
1190 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1191 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1192 base_dir = os.path.dirname(path)
1193 version = retrieve_string_singleline(base_dir, version)
1194 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1195 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1196 if string_is_integer(a):
1199 logging.warning("Problem with xml at {0}".format(path))
1201 # Remember package name, may be defined separately from version+vercode
1203 package = max_package
1205 logging.debug("..got package={0}, version={1}, vercode={2}"
1206 .format(package, version, vercode))
1208 # Always grab the package name and version name in case they are not
1209 # together with the highest version code
1210 if max_package is None and package is not None:
1211 max_package = package
1212 if max_version is None and version is not None:
1213 max_version = version
1215 if vercode is not None \
1216 and (max_vercode is None or vercode > max_vercode):
1217 if not ignoresearch or not ignoresearch(version):
1218 if version is not None:
1219 max_version = version
1220 if vercode is not None:
1221 max_vercode = vercode
1222 if package is not None:
1223 max_package = package
1225 max_version = "Ignore"
1227 if max_version is None:
1228 max_version = "Unknown"
1230 if max_package and not is_valid_package_name(max_package):
1231 raise FDroidException("Invalid package name {0}".format(max_package))
1233 return (max_version, max_vercode, max_package)
1236 def is_valid_package_name(name):
1237 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1240 class FDroidException(Exception):
1242 def __init__(self, value, detail=None):
1244 self.detail = detail
1246 def shortened_detail(self):
1247 if len(self.detail) < 16000:
1249 return '[...]\n' + self.detail[-16000:]
1251 def get_wikitext(self):
1252 ret = repr(self.value) + "\n"
1255 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1261 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1265 class VCSException(FDroidException):
1269 class BuildException(FDroidException):
1273 # Get the specified source library.
1274 # Returns the path to it. Normally this is the path to be used when referencing
1275 # it, which may be a subdirectory of the actual project. If you want the base
1276 # directory of the project, pass 'basepath=True'.
1277 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1278 raw=False, prepare=True, preponly=False, refresh=True,
1287 name, ref = spec.split('@')
1289 number, name = name.split(':', 1)
1291 name, subdir = name.split('/', 1)
1293 if name not in fdroidserver.metadata.srclibs:
1294 raise VCSException('srclib ' + name + ' not found.')
1296 srclib = fdroidserver.metadata.srclibs[name]
1298 sdir = os.path.join(srclib_dir, name)
1301 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1302 vcs.srclib = (name, number, sdir)
1304 vcs.gotorevision(ref, refresh)
1311 libdir = os.path.join(sdir, subdir)
1312 elif srclib["Subdir"]:
1313 for subdir in srclib["Subdir"]:
1314 libdir_candidate = os.path.join(sdir, subdir)
1315 if os.path.exists(libdir_candidate):
1316 libdir = libdir_candidate
1322 remove_signing_keys(sdir)
1323 remove_debuggable_flags(sdir)
1327 if srclib["Prepare"]:
1328 cmd = replace_config_vars(srclib["Prepare"], build)
1330 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1331 if p.returncode != 0:
1332 raise BuildException("Error running prepare command for srclib %s"
1338 return (name, number, libdir)
1341 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1344 # Prepare the source code for a particular build
1345 # 'vcs' - the appropriate vcs object for the application
1346 # 'app' - the application details from the metadata
1347 # 'build' - the build details from the metadata
1348 # 'build_dir' - the path to the build directory, usually
1350 # 'srclib_dir' - the path to the source libraries directory, usually
1352 # 'extlib_dir' - the path to the external libraries directory, usually
1354 # Returns the (root, srclibpaths) where:
1355 # 'root' is the root directory, which may be the same as 'build_dir' or may
1356 # be a subdirectory of it.
1357 # 'srclibpaths' is information on the srclibs being used
1358 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1360 # Optionally, the actual app source can be in a subdirectory
1362 root_dir = os.path.join(build_dir, build.subdir)
1364 root_dir = build_dir
1366 # Get a working copy of the right revision
1367 logging.info("Getting source for revision " + build.commit)
1368 vcs.gotorevision(build.commit, refresh)
1370 # Initialise submodules if required
1371 if build.submodules:
1372 logging.info("Initialising submodules")
1373 vcs.initsubmodules()
1375 # Check that a subdir (if we're using one) exists. This has to happen
1376 # after the checkout, since it might not exist elsewhere
1377 if not os.path.exists(root_dir):
1378 raise BuildException('Missing subdir ' + root_dir)
1380 # Run an init command if one is required
1382 cmd = replace_config_vars(build.init, build)
1383 logging.info("Running 'init' commands in %s" % root_dir)
1385 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1386 if p.returncode != 0:
1387 raise BuildException("Error running init command for %s:%s" %
1388 (app.id, build.versionName), p.output)
1390 # Apply patches if any
1392 logging.info("Applying patches")
1393 for patch in build.patch:
1394 patch = patch.strip()
1395 logging.info("Applying " + patch)
1396 patch_path = os.path.join('metadata', app.id, patch)
1397 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1398 if p.returncode != 0:
1399 raise BuildException("Failed to apply patch %s" % patch_path)
1401 # Get required source libraries
1404 logging.info("Collecting source libraries")
1405 for lib in build.srclibs:
1406 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1407 refresh=refresh, build=build))
1409 for name, number, libpath in srclibpaths:
1410 place_srclib(root_dir, int(number) if number else None, libpath)
1412 basesrclib = vcs.getsrclib()
1413 # If one was used for the main source, add that too.
1415 srclibpaths.append(basesrclib)
1417 # Update the local.properties file
1418 localprops = [os.path.join(build_dir, 'local.properties')]
1420 parts = build.subdir.split(os.sep)
1423 cur = os.path.join(cur, d)
1424 localprops += [os.path.join(cur, 'local.properties')]
1425 for path in localprops:
1427 if os.path.isfile(path):
1428 logging.info("Updating local.properties file at %s" % path)
1429 with open(path, 'r', encoding='iso-8859-1') as f:
1433 logging.info("Creating local.properties file at %s" % path)
1434 # Fix old-fashioned 'sdk-location' by copying
1435 # from sdk.dir, if necessary
1437 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1438 re.S | re.M).group(1)
1439 props += "sdk-location=%s\n" % sdkloc
1441 props += "sdk.dir=%s\n" % config['sdk_path']
1442 props += "sdk-location=%s\n" % config['sdk_path']
1443 ndk_path = build.ndk_path()
1444 # if for any reason the path isn't valid or the directory
1445 # doesn't exist, some versions of Gradle will error with a
1446 # cryptic message (even if the NDK is not even necessary).
1447 # https://gitlab.com/fdroid/fdroidserver/issues/171
1448 if ndk_path and os.path.exists(ndk_path):
1450 props += "ndk.dir=%s\n" % ndk_path
1451 props += "ndk-location=%s\n" % ndk_path
1452 # Add java.encoding if necessary
1454 props += "java.encoding=%s\n" % build.encoding
1455 with open(path, 'w', encoding='iso-8859-1') as f:
1459 if build.build_method() == 'gradle':
1460 flavours = build.gradle
1463 n = build.target.split('-')[1]
1464 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1465 r'compileSdkVersion %s' % n,
1466 os.path.join(root_dir, 'build.gradle'))
1468 # Remove forced debuggable flags
1469 remove_debuggable_flags(root_dir)
1471 # Insert version code and number into the manifest if necessary
1472 if build.forceversion:
1473 logging.info("Changing the version name")
1474 for path in manifest_paths(root_dir, flavours):
1475 if not os.path.isfile(path):
1477 if has_extension(path, 'xml'):
1478 regsub_file(r'android:versionName="[^"]*"',
1479 r'android:versionName="%s"' % build.versionName,
1481 elif has_extension(path, 'gradle'):
1482 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1483 r"""\1versionName '%s'""" % build.versionName,
1486 if build.forcevercode:
1487 logging.info("Changing the version code")
1488 for path in manifest_paths(root_dir, flavours):
1489 if not os.path.isfile(path):
1491 if has_extension(path, 'xml'):
1492 regsub_file(r'android:versionCode="[^"]*"',
1493 r'android:versionCode="%s"' % build.versionCode,
1495 elif has_extension(path, 'gradle'):
1496 regsub_file(r'versionCode[ =]+[0-9]+',
1497 r'versionCode %s' % build.versionCode,
1500 # Delete unwanted files
1502 logging.info("Removing specified files")
1503 for part in getpaths(build_dir, build.rm):
1504 dest = os.path.join(build_dir, part)
1505 logging.info("Removing {0}".format(part))
1506 if os.path.lexists(dest):
1507 if os.path.islink(dest):
1508 FDroidPopen(['unlink', dest], output=False)
1510 FDroidPopen(['rm', '-rf', dest], output=False)
1512 logging.info("...but it didn't exist")
1514 remove_signing_keys(build_dir)
1516 # Add required external libraries
1518 logging.info("Collecting prebuilt libraries")
1519 libsdir = os.path.join(root_dir, 'libs')
1520 if not os.path.exists(libsdir):
1522 for lib in build.extlibs:
1524 logging.info("...installing extlib {0}".format(lib))
1525 libf = os.path.basename(lib)
1526 libsrc = os.path.join(extlib_dir, lib)
1527 if not os.path.exists(libsrc):
1528 raise BuildException("Missing extlib file {0}".format(libsrc))
1529 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1531 # Run a pre-build command if one is required
1533 logging.info("Running 'prebuild' commands in %s" % root_dir)
1535 cmd = replace_config_vars(build.prebuild, build)
1537 # Substitute source library paths into prebuild commands
1538 for name, number, libpath in srclibpaths:
1539 libpath = os.path.relpath(libpath, root_dir)
1540 cmd = cmd.replace('$$' + name + '$$', libpath)
1542 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1543 if p.returncode != 0:
1544 raise BuildException("Error running prebuild command for %s:%s" %
1545 (app.id, build.versionName), p.output)
1547 # Generate (or update) the ant build file, build.xml...
1548 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1549 parms = ['android', 'update', 'lib-project']
1550 lparms = ['android', 'update', 'project']
1553 parms += ['-t', build.target]
1554 lparms += ['-t', build.target]
1555 if build.androidupdate:
1556 update_dirs = build.androidupdate
1558 update_dirs = ant_subprojects(root_dir) + ['.']
1560 for d in update_dirs:
1561 subdir = os.path.join(root_dir, d)
1563 logging.debug("Updating main project")
1564 cmd = parms + ['-p', d]
1566 logging.debug("Updating subproject %s" % d)
1567 cmd = lparms + ['-p', d]
1568 p = SdkToolsPopen(cmd, cwd=root_dir)
1569 # Check to see whether an error was returned without a proper exit
1570 # code (this is the case for the 'no target set or target invalid'
1572 if p.returncode != 0 or p.output.startswith("Error: "):
1573 raise BuildException("Failed to update project at %s" % d, p.output)
1574 # Clean update dirs via ant
1576 logging.info("Cleaning subproject %s" % d)
1577 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1579 return (root_dir, srclibpaths)
1582 # Extend via globbing the paths from a field and return them as a map from
1583 # original path to resulting paths
1584 def getpaths_map(build_dir, globpaths):
1588 full_path = os.path.join(build_dir, p)
1589 full_path = os.path.normpath(full_path)
1590 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1592 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1596 # Extend via globbing the paths from a field and return them as a set
1597 def getpaths(build_dir, globpaths):
1598 paths_map = getpaths_map(build_dir, globpaths)
1600 for k, v in paths_map.items():
1607 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1613 self.path = os.path.join('stats', 'known_apks.txt')
1615 if os.path.isfile(self.path):
1616 with open(self.path, 'r', encoding='utf8') as f:
1618 t = line.rstrip().split(' ')
1620 self.apks[t[0]] = (t[1], None)
1622 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1623 self.changed = False
1625 def writeifchanged(self):
1626 if not self.changed:
1629 if not os.path.exists('stats'):
1633 for apk, app in self.apks.items():
1635 line = apk + ' ' + appid
1637 line += ' ' + added.strftime('%Y-%m-%d')
1640 with open(self.path, 'w', encoding='utf8') as f:
1641 for line in sorted(lst, key=natural_key):
1642 f.write(line + '\n')
1644 def recordapk(self, apk, app, default_date=None):
1646 Record an apk (if it's new, otherwise does nothing)
1647 Returns the date it was added as a datetime instance
1649 if apk not in self.apks:
1650 if default_date is None:
1651 default_date = datetime.utcnow()
1652 self.apks[apk] = (app, default_date)
1654 _, added = self.apks[apk]
1657 # Look up information - given the 'apkname', returns (app id, date added/None).
1658 # Or returns None for an unknown apk.
1659 def getapp(self, apkname):
1660 if apkname in self.apks:
1661 return self.apks[apkname]
1664 # Get the most recent 'num' apps added to the repo, as a list of package ids
1665 # with the most recent first.
1666 def getlatest(self, num):
1668 for apk, app in self.apks.items():
1672 if apps[appid] > added:
1676 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1677 lst = [app for app, _ in sortedapps]
1682 def get_file_extension(filename):
1683 """get the normalized file extension, can be blank string but never None"""
1684 if isinstance(filename, bytes):
1685 filename = filename.decode('utf-8')
1686 return os.path.splitext(filename)[1].lower()[1:]
1689 def isApkAndDebuggable(apkfile, config):
1690 """Returns True if the given file is an APK and is debuggable
1692 :param apkfile: full path to the apk to check"""
1694 if get_file_extension(apkfile) != 'apk':
1697 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1699 if p.returncode != 0:
1700 logging.critical("Failed to get apk manifest information")
1702 for line in p.output.splitlines():
1703 if 'android:debuggable' in line and not line.endswith('0x0'):
1710 self.returncode = None
1714 def SdkToolsPopen(commands, cwd=None, output=True):
1716 if cmd not in config:
1717 config[cmd] = find_sdk_tools_cmd(commands[0])
1718 abscmd = config[cmd]
1720 logging.critical("Could not find '%s' on your system" % cmd)
1723 test_aapt_version(config['aapt'])
1724 return FDroidPopen([abscmd] + commands[1:],
1725 cwd=cwd, output=output)
1728 def FDroidPopenBytes(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1730 Run a command and capture the possibly huge output as bytes.
1732 :param commands: command and argument list like in subprocess.Popen
1733 :param cwd: optionally specifies a working directory
1734 :param envs: a optional dictionary of environment variables and their values
1735 :returns: A PopenResult.
1740 set_FDroidPopen_env()
1742 process_env = env.copy()
1743 if envs is not None and len(envs) > 0:
1744 process_env.update(envs)
1747 cwd = os.path.normpath(cwd)
1748 logging.debug("Directory: %s" % cwd)
1749 logging.debug("> %s" % ' '.join(commands))
1751 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1752 result = PopenResult()
1755 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=process_env,
1756 stdout=subprocess.PIPE, stderr=stderr_param)
1757 except OSError as e:
1758 raise BuildException("OSError while trying to execute " +
1759 ' '.join(commands) + ': ' + str(e))
1761 if not stderr_to_stdout and options.verbose:
1762 stderr_queue = Queue()
1763 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1765 while not stderr_reader.eof():
1766 while not stderr_queue.empty():
1767 line = stderr_queue.get()
1768 sys.stderr.buffer.write(line)
1773 stdout_queue = Queue()
1774 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1777 # Check the queue for output (until there is no more to get)
1778 while not stdout_reader.eof():
1779 while not stdout_queue.empty():
1780 line = stdout_queue.get()
1781 if output and options.verbose:
1782 # Output directly to console
1783 sys.stderr.buffer.write(line)
1789 result.returncode = p.wait()
1790 result.output = buf.getvalue()
1795 def FDroidPopen(commands, cwd=None, envs=None, output=True, stderr_to_stdout=True):
1797 Run a command and capture the possibly huge output as a str.
1799 :param commands: command and argument list like in subprocess.Popen
1800 :param cwd: optionally specifies a working directory
1801 :param envs: a optional dictionary of environment variables and their values
1802 :returns: A PopenResult.
1804 result = FDroidPopenBytes(commands, cwd, envs, output, stderr_to_stdout)
1805 result.output = result.output.decode('utf-8', 'ignore')
1809 gradle_comment = re.compile(r'[ ]*//')
1810 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1811 gradle_line_matches = [
1812 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1813 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1814 re.compile(r'.*\.readLine\(.*'),
1818 def remove_signing_keys(build_dir):
1819 for root, dirs, files in os.walk(build_dir):
1820 if 'build.gradle' in files:
1821 path = os.path.join(root, 'build.gradle')
1823 with open(path, "r", encoding='utf8') as o:
1824 lines = o.readlines()
1830 with open(path, "w", encoding='utf8') as o:
1831 while i < len(lines):
1834 while line.endswith('\\\n'):
1835 line = line.rstrip('\\\n') + lines[i]
1838 if gradle_comment.match(line):
1843 opened += line.count('{')
1844 opened -= line.count('}')
1847 if gradle_signing_configs.match(line):
1852 if any(s.match(line) for s in gradle_line_matches):
1860 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1863 'project.properties',
1865 'default.properties',
1866 'ant.properties', ]:
1867 if propfile in files:
1868 path = os.path.join(root, propfile)
1870 with open(path, "r", encoding='iso-8859-1') as o:
1871 lines = o.readlines()
1875 with open(path, "w", encoding='iso-8859-1') as o:
1877 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1884 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1887 def set_FDroidPopen_env(build=None):
1889 set up the environment variables for the build environment
1891 There is only a weak standard, the variables used by gradle, so also set
1892 up the most commonly used environment variables for SDK and NDK. Also, if
1893 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1895 global env, orig_path
1899 orig_path = env['PATH']
1900 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1901 env[n] = config['sdk_path']
1902 for k, v in config['java_paths'].items():
1903 env['JAVA%s_HOME' % k] = v
1905 missinglocale = True
1906 for k, v in env.items():
1907 if k == 'LANG' and v != 'C':
1908 missinglocale = False
1910 missinglocale = False
1912 env['LANG'] = 'en_US.UTF-8'
1914 if build is not None:
1915 path = build.ndk_path()
1916 paths = orig_path.split(os.pathsep)
1917 if path not in paths:
1918 paths = [path] + paths
1919 env['PATH'] = os.pathsep.join(paths)
1920 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1921 env[n] = build.ndk_path()
1924 def replace_build_vars(cmd, build):
1925 cmd = cmd.replace('$$COMMIT$$', build.commit)
1926 cmd = cmd.replace('$$VERSION$$', build.versionName)
1927 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1931 def replace_config_vars(cmd, build):
1932 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1933 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1934 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1935 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1936 if build is not None:
1937 cmd = replace_build_vars(cmd, build)
1941 def place_srclib(root_dir, number, libpath):
1944 relpath = os.path.relpath(libpath, root_dir)
1945 proppath = os.path.join(root_dir, 'project.properties')
1948 if os.path.isfile(proppath):
1949 with open(proppath, "r", encoding='iso-8859-1') as o:
1950 lines = o.readlines()
1952 with open(proppath, "w", encoding='iso-8859-1') as o:
1955 if line.startswith('android.library.reference.%d=' % number):
1956 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1961 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1964 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1967 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1968 """Verify that two apks are the same
1970 One of the inputs is signed, the other is unsigned. The signature metadata
1971 is transferred from the signed to the unsigned apk, and then jarsigner is
1972 used to verify that the signature from the signed apk is also varlid for
1973 the unsigned one. If the APK given as unsigned actually does have a
1974 signature, it will be stripped out and ignored.
1976 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1977 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1978 into AndroidManifest.xml, but that makes the build not reproducible. So
1979 instead they are included as separate files in the APK's META-INF/ folder.
1980 If those files exist in the signed APK, they will be part of the signature
1981 and need to also be included in the unsigned APK for it to validate.
1983 :param signed_apk: Path to a signed apk file
1984 :param unsigned_apk: Path to an unsigned apk file expected to match it
1985 :param tmp_dir: Path to directory for temporary files
1986 :returns: None if the verification is successful, otherwise a string
1987 describing what went wrong.
1990 signed = ZipFile(signed_apk, 'r')
1991 meta_inf_files = ['META-INF/MANIFEST.MF']
1992 for f in signed.namelist():
1993 if apk_sigfile.match(f) \
1994 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
1995 meta_inf_files.append(f)
1996 if len(meta_inf_files) < 3:
1997 return "Signature files missing from {0}".format(signed_apk)
1999 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2000 unsigned = ZipFile(unsigned_apk, 'r')
2001 # only read the signature from the signed APK, everything else from unsigned
2002 with ZipFile(tmp_apk, 'w') as tmp:
2003 for filename in meta_inf_files:
2004 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2005 for info in unsigned.infolist():
2006 if info.filename in meta_inf_files:
2007 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2009 if info.filename in tmp.namelist():
2010 return "duplicate filename found: " + info.filename
2011 tmp.writestr(info, unsigned.read(info.filename))
2015 verified = verify_apk_signature(tmp_apk)
2018 logging.info("...NOT verified - {0}".format(tmp_apk))
2019 return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2021 logging.info("...successfully verified")
2025 def verify_apk_signature(apk, jar=False):
2026 """verify the signature on an APK
2028 Try to use apksigner whenever possible since jarsigner is very
2029 shitty: unsigned APKs pass as "verified"! So this has to turn on
2030 -strict then check for result 4.
2032 You can set :param: jar to True if you want to use this method
2033 to verify jar signatures.
2035 if set_command_in_config('apksigner'):
2036 args = [config['apksigner'], 'verify']
2038 args += ['--min-sdk-version=1']
2039 return subprocess.call(args + [apk]) == 0
2041 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2042 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2045 apk_badchars = re.compile('''[/ :;'"]''')
2048 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2051 Returns None if the apk content is the same (apart from the signing key),
2052 otherwise a string describing what's different, or what went wrong when
2053 trying to do the comparison.
2059 absapk1 = os.path.abspath(apk1)
2060 absapk2 = os.path.abspath(apk2)
2062 if set_command_in_config('diffoscope'):
2063 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2064 htmlfile = logfilename + '.diffoscope.html'
2065 textfile = logfilename + '.diffoscope.txt'
2066 if subprocess.call([config['diffoscope'],
2067 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2068 '--html', htmlfile, '--text', textfile,
2069 absapk1, absapk2]) != 0:
2070 return("Failed to unpack " + apk1)
2072 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2073 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2074 for d in [apk1dir, apk2dir]:
2075 if os.path.exists(d):
2078 os.mkdir(os.path.join(d, 'jar-xf'))
2080 if subprocess.call(['jar', 'xf',
2081 os.path.abspath(apk1)],
2082 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2083 return("Failed to unpack " + apk1)
2084 if subprocess.call(['jar', 'xf',
2085 os.path.abspath(apk2)],
2086 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2087 return("Failed to unpack " + apk2)
2089 if set_command_in_config('apktool'):
2090 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2092 return("Failed to unpack " + apk1)
2093 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2095 return("Failed to unpack " + apk2)
2097 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2098 lines = p.output.splitlines()
2099 if len(lines) != 1 or 'META-INF' not in lines[0]:
2100 meld = find_command('meld')
2101 if meld is not None:
2102 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2103 return("Unexpected diff output - " + p.output)
2105 # since everything verifies, delete the comparison to keep cruft down
2106 shutil.rmtree(apk1dir)
2107 shutil.rmtree(apk2dir)
2109 # If we get here, it seems like they're the same!
2113 def set_command_in_config(command):
2114 '''Try to find specified command in the path, if it hasn't been
2115 manually set in config.py. If found, it is added to the config
2116 dict. The return value says whether the command is available.
2119 if command in config:
2122 tmp = find_command(command)
2124 config[command] = tmp
2129 def find_command(command):
2130 '''find the full path of a command, or None if it can't be found in the PATH'''
2133 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2135 fpath, fname = os.path.split(command)
2140 for path in os.environ["PATH"].split(os.pathsep):
2141 path = path.strip('"')
2142 exe_file = os.path.join(path, command)
2143 if is_exe(exe_file):
2150 '''generate a random password for when generating keys'''
2151 h = hashlib.sha256()
2152 h.update(os.urandom(16)) # salt
2153 h.update(socket.getfqdn().encode('utf-8'))
2154 passwd = base64.b64encode(h.digest()).strip()
2155 return passwd.decode('utf-8')
2158 def genkeystore(localconfig):
2160 Generate a new key with password provided in :param localconfig and add it to new keystore
2161 :return: hexed public key, public key fingerprint
2163 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2164 keystoredir = os.path.dirname(localconfig['keystore'])
2165 if keystoredir is None or keystoredir == '':
2166 keystoredir = os.path.join(os.getcwd(), keystoredir)
2167 if not os.path.exists(keystoredir):
2168 os.makedirs(keystoredir, mode=0o700)
2171 'FDROID_KEY_STORE_PASS': localconfig['keystorepass'],
2172 'FDROID_KEY_PASS': localconfig['keypass'],
2174 p = FDroidPopen([config['keytool'], '-genkey',
2175 '-keystore', localconfig['keystore'],
2176 '-alias', localconfig['repo_keyalias'],
2177 '-keyalg', 'RSA', '-keysize', '4096',
2178 '-sigalg', 'SHA256withRSA',
2179 '-validity', '10000',
2180 '-storepass:env', 'FDROID_KEY_STORE_PASS',
2181 '-keypass:env', 'FDROID_KEY_PASS',
2182 '-dname', localconfig['keydname']], envs=env_vars)
2183 if p.returncode != 0:
2184 raise BuildException("Failed to generate key", p.output)
2185 os.chmod(localconfig['keystore'], 0o0600)
2186 if not options.quiet:
2187 # now show the lovely key that was just generated
2188 p = FDroidPopen([config['keytool'], '-list', '-v',
2189 '-keystore', localconfig['keystore'],
2190 '-alias', localconfig['repo_keyalias'],
2191 '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
2192 logging.info(p.output.strip() + '\n\n')
2193 # get the public key
2194 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2195 '-keystore', localconfig['keystore'],
2196 '-alias', localconfig['repo_keyalias'],
2197 '-storepass:env', 'FDROID_KEY_STORE_PASS']
2198 + config['smartcardoptions'],
2199 envs=env_vars, output=False, stderr_to_stdout=False)
2200 if p.returncode != 0 or len(p.output) < 20:
2201 raise BuildException("Failed to get public key", p.output)
2203 fingerprint = get_cert_fingerprint(pubkey)
2204 return hexlify(pubkey), fingerprint
2207 def get_cert_fingerprint(pubkey):
2209 Generate a certificate fingerprint the same way keytool does it
2210 (but with slightly different formatting)
2212 digest = hashlib.sha256(pubkey).digest()
2213 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2214 return " ".join(ret)
2217 def get_certificate(certificate_file):
2219 Extracts a certificate from the given file.
2220 :param certificate_file: file bytes (as string) representing the certificate
2221 :return: A binary representation of the certificate's public key, or None in case of error
2223 content = decoder.decode(certificate_file, asn1Spec=rfc2315.ContentInfo())[0]
2224 if content.getComponentByName('contentType') != rfc2315.signedData:
2226 content = decoder.decode(content.getComponentByName('content'),
2227 asn1Spec=rfc2315.SignedData())[0]
2229 certificates = content.getComponentByName('certificates')
2230 cert = certificates[0].getComponentByName('certificate')
2232 logging.error("Certificates not found.")
2234 return encoder.encode(cert)
2237 def write_to_config(thisconfig, key, value=None, config_file=None):
2238 '''write a key/value to the local config.py
2240 NOTE: only supports writing string variables.
2242 :param thisconfig: config dictionary
2243 :param key: variable name in config.py to be overwritten/added
2244 :param value: optional value to be written, instead of fetched
2245 from 'thisconfig' dictionary.
2248 origkey = key + '_orig'
2249 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2250 cfg = config_file if config_file else 'config.py'
2253 with open(cfg, 'r', encoding="utf-8") as f:
2254 lines = f.readlines()
2256 # make sure the file ends with a carraige return
2258 if not lines[-1].endswith('\n'):
2261 # regex for finding and replacing python string variable
2262 # definitions/initializations
2263 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2264 repl = key + ' = "' + value + '"'
2265 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2266 repl2 = key + " = '" + value + "'"
2268 # If we replaced this line once, we make sure won't be a
2269 # second instance of this line for this key in the document.
2272 with open(cfg, 'w', encoding="utf-8") as f:
2274 if pattern.match(line) or pattern2.match(line):
2276 line = pattern.sub(repl, line)
2277 line = pattern2.sub(repl2, line)
2288 def parse_xml(path):
2289 return XMLElementTree.parse(path).getroot()
2292 def string_is_integer(string):
2300 def get_per_app_repos():
2301 '''per-app repos are dirs named with the packageName of a single app'''
2303 # Android packageNames are Java packages, they may contain uppercase or
2304 # lowercase letters ('A' through 'Z'), numbers, and underscores
2305 # ('_'). However, individual package name parts may only start with
2306 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2307 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2310 for root, dirs, files in os.walk(os.getcwd()):
2312 print('checking', root, 'for', d)
2313 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2314 # standard parts of an fdroid repo, so never packageNames
2317 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2323 def is_repo_file(filename):
2324 '''Whether the file in a repo is a build product to be delivered to users'''
2325 if isinstance(filename, str):
2326 filename = filename.encode('utf-8', errors="surrogateescape")
2327 return os.path.isfile(filename) \
2328 and not filename.endswith(b'.asc') \
2329 and not filename.endswith(b'.sig') \
2330 and os.path.basename(filename) not in [
2332 b'index_unsigned.jar',