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 import fdroidserver.metadata
46 from .asynchronousfilereader import AsynchronousFileReader
49 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
58 'sdk_path': "$ANDROID_HOME",
63 'r12b': "$ANDROID_NDK",
68 'build_tools': "25.0.2",
69 'force_build_tools': False,
74 'accepted_formats': ['txt', 'yml'],
75 'sync_from_local_copy_dir': False,
76 'per_app_repos': False,
77 'make_current_version_link': True,
78 'current_version_name_source': 'Name',
79 'update_stats': False,
83 'stats_to_carbon': False,
85 'build_server_always': False,
86 'keystore': 'keystore.jks',
87 'smartcardoptions': [],
93 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
94 'repo_name': "My First FDroid Repo Demo",
95 'repo_icon': "fdroid-icon.png",
96 'repo_description': '''
97 This is a repository of apps to be used with FDroid. Applications in this
98 repository are either official binaries built by the original application
99 developers, or are binaries built from source by the admin of f-droid.org
100 using the tools on https://gitlab.com/u/fdroid.
106 def setup_global_opts(parser):
107 parser.add_argument("-v", "--verbose", action="store_true", default=False,
108 help="Spew out even more information than normal")
109 parser.add_argument("-q", "--quiet", action="store_true", default=False,
110 help="Restrict output to warnings and errors")
113 def fill_config_defaults(thisconfig):
114 for k, v in default_config.items():
115 if k not in thisconfig:
118 # Expand paths (~users and $vars)
119 def expand_path(path):
123 path = os.path.expanduser(path)
124 path = os.path.expandvars(path)
129 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
134 thisconfig[k + '_orig'] = v
136 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
137 if thisconfig['java_paths'] is None:
138 thisconfig['java_paths'] = dict()
140 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
141 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
142 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
143 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
144 if os.getenv('JAVA_HOME') is not None:
145 pathlist.append(os.getenv('JAVA_HOME'))
146 if os.getenv('PROGRAMFILES') is not None:
147 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
148 for d in sorted(pathlist):
149 if os.path.islink(d):
151 j = os.path.basename(d)
152 # the last one found will be the canonical one, so order appropriately
154 r'^1\.([6-9])\.0\.jdk$', # OSX
155 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
156 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
157 r'^jdk([6-9])-openjdk$', # Arch
158 r'^java-([6-9])-openjdk$', # Arch
159 r'^java-([6-9])-jdk$', # Arch (oracle)
160 r'^java-1\.([6-9])\.0-.*$', # RedHat
161 r'^java-([6-9])-oracle$', # Debian WebUpd8
162 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
163 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
165 m = re.match(regex, j)
168 for p in [d, os.path.join(d, 'Contents', 'Home')]:
169 if os.path.exists(os.path.join(p, 'bin', 'javac')):
170 thisconfig['java_paths'][m.group(1)] = p
172 for java_version in ('7', '8', '9'):
173 if java_version not in thisconfig['java_paths']:
175 java_home = thisconfig['java_paths'][java_version]
176 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
177 if os.path.exists(jarsigner):
178 thisconfig['jarsigner'] = jarsigner
179 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
180 break # Java7 is preferred, so quit if found
182 for k in ['ndk_paths', 'java_paths']:
188 thisconfig[k][k2] = exp
189 thisconfig[k][k2 + '_orig'] = v
192 def regsub_file(pattern, repl, path):
193 with open(path, 'rb') as f:
195 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
196 with open(path, 'wb') as f:
200 def read_config(opts, config_file='config.py'):
201 """Read the repository config
203 The config is read from config_file, which is in the current
204 directory when any of the repo management commands are used. If
205 there is a local metadata file in the git repo, then config.py is
206 not required, just use defaults.
209 global config, options
211 if config is not None:
218 if os.path.isfile(config_file):
219 logging.debug("Reading %s" % config_file)
220 with io.open(config_file, "rb") as f:
221 code = compile(f.read(), config_file, 'exec')
222 exec(code, None, config)
223 elif len(get_local_metadata_files()) == 0:
224 logging.critical("Missing config file - is this a repo directory?")
227 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
229 if not type(config[k]) in (str, list, tuple):
230 logging.warn('"' + k + '" will be in random order!'
231 + ' Use () or [] brackets if order is important!')
233 # smartcardoptions must be a list since its command line args for Popen
234 if 'smartcardoptions' in config:
235 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
236 elif 'keystore' in config and config['keystore'] == 'NONE':
237 # keystore='NONE' means use smartcard, these are required defaults
238 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
239 'SunPKCS11-OpenSC', '-providerClass',
240 'sun.security.pkcs11.SunPKCS11',
241 '-providerArg', 'opensc-fdroid.cfg']
243 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
244 st = os.stat(config_file)
245 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
246 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
248 fill_config_defaults(config)
250 for k in ["keystorepass", "keypass"]:
252 write_password_file(k)
254 for k in ["repo_description", "archive_description"]:
256 config[k] = clean_description(config[k])
258 if 'serverwebroot' in config:
259 if isinstance(config['serverwebroot'], str):
260 roots = [config['serverwebroot']]
261 elif all(isinstance(item, str) for item in config['serverwebroot']):
262 roots = config['serverwebroot']
264 raise TypeError('only accepts strings, lists, and tuples')
266 for rootstr in roots:
267 # since this is used with rsync, where trailing slashes have
268 # meaning, ensure there is always a trailing slash
269 if rootstr[-1] != '/':
271 rootlist.append(rootstr.replace('//', '/'))
272 config['serverwebroot'] = rootlist
274 if 'servergitmirrors' in config:
275 if isinstance(config['servergitmirrors'], str):
276 roots = [config['servergitmirrors']]
277 elif all(isinstance(item, str) for item in config['servergitmirrors']):
278 roots = config['servergitmirrors']
280 raise TypeError('only accepts strings, lists, and tuples')
281 config['servergitmirrors'] = roots
286 def find_sdk_tools_cmd(cmd):
287 '''find a working path to a tool from the Android SDK'''
290 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
291 # try to find a working path to this command, in all the recent possible paths
292 if 'build_tools' in config:
293 build_tools = os.path.join(config['sdk_path'], 'build-tools')
294 # if 'build_tools' was manually set and exists, check only that one
295 configed_build_tools = os.path.join(build_tools, config['build_tools'])
296 if os.path.exists(configed_build_tools):
297 tooldirs.append(configed_build_tools)
299 # no configed version, so hunt known paths for it
300 for f in sorted(os.listdir(build_tools), reverse=True):
301 if os.path.isdir(os.path.join(build_tools, f)):
302 tooldirs.append(os.path.join(build_tools, f))
303 tooldirs.append(build_tools)
304 sdk_tools = os.path.join(config['sdk_path'], 'tools')
305 if os.path.exists(sdk_tools):
306 tooldirs.append(sdk_tools)
307 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
308 if os.path.exists(sdk_platform_tools):
309 tooldirs.append(sdk_platform_tools)
310 tooldirs.append('/usr/bin')
312 path = os.path.join(d, cmd)
313 if os.path.isfile(path):
315 test_aapt_version(path)
317 # did not find the command, exit with error message
318 ensure_build_tools_exists(config)
321 def test_aapt_version(aapt):
322 '''Check whether the version of aapt is new enough'''
323 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
324 if output is None or output == '':
325 logging.error(aapt + ' failed to execute!')
327 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
332 # the Debian package has the version string like "v0.2-23.0.2"
333 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
334 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
336 logging.warning('Unknown version of aapt, might cause problems: ' + output)
339 def test_sdk_exists(thisconfig):
340 if 'sdk_path' not in thisconfig:
341 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
342 test_aapt_version(thisconfig['aapt'])
345 logging.error("'sdk_path' not set in config.py!")
347 if thisconfig['sdk_path'] == default_config['sdk_path']:
348 logging.error('No Android SDK found!')
349 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
350 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
352 if not os.path.exists(thisconfig['sdk_path']):
353 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
355 if not os.path.isdir(thisconfig['sdk_path']):
356 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
358 for d in ['build-tools', 'platform-tools', 'tools']:
359 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
360 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
361 thisconfig['sdk_path'], d))
366 def ensure_build_tools_exists(thisconfig):
367 if not test_sdk_exists(thisconfig):
369 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
370 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
371 if not os.path.isdir(versioned_build_tools):
372 logging.critical('Android Build Tools path "'
373 + versioned_build_tools + '" does not exist!')
377 def write_password_file(pwtype, password=None):
379 writes out passwords to a protected file instead of passing passwords as
380 command line argments
382 filename = '.fdroid.' + pwtype + '.txt'
383 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
385 os.write(fd, config[pwtype].encode('utf-8'))
387 os.write(fd, password.encode('utf-8'))
389 config[pwtype + 'file'] = filename
392 def get_local_metadata_files():
393 '''get any metadata files local to an app's source repo
395 This tries to ignore anything that does not count as app metdata,
396 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
399 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
402 def read_pkg_args(args, allow_vercodes=False):
404 Given the arguments in the form of multiple appid:[vc] strings, this returns
405 a dictionary with the set of vercodes specified for each package.
413 if allow_vercodes and ':' in p:
414 package, vercode = p.split(':')
416 package, vercode = p, None
417 if package not in vercodes:
418 vercodes[package] = [vercode] if vercode else []
420 elif vercode and vercode not in vercodes[package]:
421 vercodes[package] += [vercode] if vercode else []
426 def read_app_args(args, allapps, allow_vercodes=False):
428 On top of what read_pkg_args does, this returns the whole app metadata, but
429 limiting the builds list to the builds matching the vercodes specified.
432 vercodes = read_pkg_args(args, allow_vercodes)
438 for appid, app in allapps.items():
439 if appid in vercodes:
442 if len(apps) != len(vercodes):
445 logging.critical("No such package: %s" % p)
446 raise FDroidException("Found invalid app ids in arguments")
448 raise FDroidException("No packages specified")
451 for appid, app in apps.items():
455 app.builds = [b for b in app.builds if b.versionCode in vc]
456 if len(app.builds) != len(vercodes[appid]):
458 allvcs = [b.versionCode for b in app.builds]
459 for v in vercodes[appid]:
461 logging.critical("No such vercode %s for app %s" % (v, appid))
464 raise FDroidException("Found invalid vercodes for some apps")
469 def get_extension(filename):
470 base, ext = os.path.splitext(filename)
473 return base, ext.lower()[1:]
476 def has_extension(filename, ext):
477 _, f_ext = get_extension(filename)
481 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
484 def clean_description(description):
485 'Remove unneeded newlines and spaces from a block of description text'
487 # this is split up by paragraph to make removing the newlines easier
488 for paragraph in re.split(r'\n\n', description):
489 paragraph = re.sub('\r', '', paragraph)
490 paragraph = re.sub('\n', ' ', paragraph)
491 paragraph = re.sub(' {2,}', ' ', paragraph)
492 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
493 returnstring += paragraph + '\n\n'
494 return returnstring.rstrip('\n')
497 def publishednameinfo(filename):
498 filename = os.path.basename(filename)
499 m = publish_name_regex.match(filename)
501 result = (m.group(1), m.group(2))
502 except AttributeError:
503 raise FDroidException("Invalid name for published file: %s" % filename)
507 def get_release_filename(app, build):
509 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
511 return "%s_%s.apk" % (app.id, build.versionCode)
514 def get_toolsversion_logname(app, build):
515 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
518 def getsrcname(app, build):
519 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
531 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
534 def get_build_dir(app):
535 '''get the dir that this app will be built in'''
537 if app.RepoType == 'srclib':
538 return os.path.join('build', 'srclib', app.Repo)
540 return os.path.join('build', app.id)
544 '''checkout code from VCS and return instance of vcs and the build dir'''
545 build_dir = get_build_dir(app)
547 # Set up vcs interface and make sure we have the latest code...
548 logging.debug("Getting {0} vcs interface for {1}"
549 .format(app.RepoType, app.Repo))
550 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
554 vcs = getvcs(app.RepoType, remote, build_dir)
556 return vcs, build_dir
559 def getvcs(vcstype, remote, local):
561 return vcs_git(remote, local)
562 if vcstype == 'git-svn':
563 return vcs_gitsvn(remote, local)
565 return vcs_hg(remote, local)
567 return vcs_bzr(remote, local)
568 if vcstype == 'srclib':
569 if local != os.path.join('build', 'srclib', remote):
570 raise VCSException("Error: srclib paths are hard-coded!")
571 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
573 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
574 raise VCSException("Invalid vcs type " + vcstype)
577 def getsrclibvcs(name):
578 if name not in fdroidserver.metadata.srclibs:
579 raise VCSException("Missing srclib " + name)
580 return fdroidserver.metadata.srclibs[name]['Repo Type']
585 def __init__(self, remote, local):
587 # svn, git-svn and bzr may require auth
589 if self.repotype() in ('git-svn', 'bzr'):
591 if self.repotype == 'git-svn':
592 raise VCSException("Authentication is not supported for git-svn")
593 self.username, remote = remote.split('@')
594 if ':' not in self.username:
595 raise VCSException("Password required with username")
596 self.username, self.password = self.username.split(':')
600 self.clone_failed = False
601 self.refreshed = False
607 # Take the local repository to a clean version of the given revision, which
608 # is specificed in the VCS's native format. Beforehand, the repository can
609 # be dirty, or even non-existent. If the repository does already exist
610 # locally, it will be updated from the origin, but only once in the
611 # lifetime of the vcs object.
612 # None is acceptable for 'rev' if you know you are cloning a clean copy of
613 # the repo - otherwise it must specify a valid revision.
614 def gotorevision(self, rev, refresh=True):
616 if self.clone_failed:
617 raise VCSException("Downloading the repository already failed once, not trying again.")
619 # The .fdroidvcs-id file for a repo tells us what VCS type
620 # and remote that directory was created from, allowing us to drop it
621 # automatically if either of those things changes.
622 fdpath = os.path.join(self.local, '..',
623 '.fdroidvcs-' + os.path.basename(self.local))
624 fdpath = os.path.normpath(fdpath)
625 cdata = self.repotype() + ' ' + self.remote
628 if os.path.exists(self.local):
629 if os.path.exists(fdpath):
630 with open(fdpath, 'r') as f:
631 fsdata = f.read().strip()
636 logging.info("Repository details for %s changed - deleting" % (
640 logging.info("Repository details for %s missing - deleting" % (
643 shutil.rmtree(self.local)
647 self.refreshed = True
650 self.gotorevisionx(rev)
651 except FDroidException as e:
654 # If necessary, write the .fdroidvcs file.
655 if writeback and not self.clone_failed:
656 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
657 with open(fdpath, 'w+') as f:
663 # Derived classes need to implement this. It's called once basic checking
664 # has been performend.
665 def gotorevisionx(self, rev):
666 raise VCSException("This VCS type doesn't define gotorevisionx")
668 # Initialise and update submodules
669 def initsubmodules(self):
670 raise VCSException('Submodules not supported for this vcs type')
672 # Get a list of all known tags
674 if not self._gettags:
675 raise VCSException('gettags not supported for this vcs type')
677 for tag in self._gettags():
678 if re.match('[-A-Za-z0-9_. /]+$', tag):
682 # Get a list of all the known tags, sorted from newest to oldest
683 def latesttags(self):
684 raise VCSException('latesttags not supported for this vcs type')
686 # Get current commit reference (hash, revision, etc)
688 raise VCSException('getref not supported for this vcs type')
690 # Returns the srclib (name, path) used in setting up the current
701 # If the local directory exists, but is somehow not a git repository, git
702 # will traverse up the directory tree until it finds one that is (i.e.
703 # fdroidserver) and then we'll proceed to destroy it! This is called as
706 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
707 result = p.output.rstrip()
708 if not result.endswith(self.local):
709 raise VCSException('Repository mismatch')
711 def gotorevisionx(self, rev):
712 if not os.path.exists(self.local):
714 p = FDroidPopen(['git', 'clone', self.remote, self.local])
715 if p.returncode != 0:
716 self.clone_failed = True
717 raise VCSException("Git clone failed", p.output)
721 # Discard any working tree changes
722 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
723 'git', 'reset', '--hard'], cwd=self.local, output=False)
724 if p.returncode != 0:
725 raise VCSException("Git reset failed", p.output)
726 # Remove untracked files now, in case they're tracked in the target
727 # revision (it happens!)
728 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
729 'git', 'clean', '-dffx'], cwd=self.local, output=False)
730 if p.returncode != 0:
731 raise VCSException("Git clean failed", p.output)
732 if not self.refreshed:
733 # Get latest commits and tags from remote
734 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
735 if p.returncode != 0:
736 raise VCSException("Git fetch failed", p.output)
737 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
738 if p.returncode != 0:
739 raise VCSException("Git fetch failed", p.output)
740 # Recreate origin/HEAD as git clone would do it, in case it disappeared
741 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
742 if p.returncode != 0:
743 lines = p.output.splitlines()
744 if 'Multiple remote HEAD branches' not in lines[0]:
745 raise VCSException("Git remote set-head failed", p.output)
746 branch = lines[1].split(' ')[-1]
747 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
748 if p2.returncode != 0:
749 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
750 self.refreshed = True
751 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
752 # a github repo. Most of the time this is the same as origin/master.
753 rev = rev or 'origin/HEAD'
754 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
755 if p.returncode != 0:
756 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
757 # Get rid of any uncontrolled files left behind
758 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
759 if p.returncode != 0:
760 raise VCSException("Git clean failed", p.output)
762 def initsubmodules(self):
764 submfile = os.path.join(self.local, '.gitmodules')
765 if not os.path.isfile(submfile):
766 raise VCSException("No git submodules available")
768 # fix submodules not accessible without an account and public key auth
769 with open(submfile, 'r') as f:
770 lines = f.readlines()
771 with open(submfile, 'w') as f:
773 if 'git@github.com' in line:
774 line = line.replace('git@github.com:', 'https://github.com/')
775 if 'git@gitlab.com' in line:
776 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
779 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
780 if p.returncode != 0:
781 raise VCSException("Git submodule sync failed", p.output)
782 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
783 if p.returncode != 0:
784 raise VCSException("Git submodule update failed", p.output)
788 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
789 return p.output.splitlines()
791 tag_format = re.compile(r'tag: ([^),]*)')
793 def latesttags(self):
795 p = FDroidPopen(['git', 'log', '--tags',
796 '--simplify-by-decoration', '--pretty=format:%d'],
797 cwd=self.local, output=False)
799 for line in p.output.splitlines():
800 for tag in self.tag_format.findall(line):
805 class vcs_gitsvn(vcs):
810 # If the local directory exists, but is somehow not a git repository, git
811 # will traverse up the directory tree until it finds one that is (i.e.
812 # fdroidserver) and then we'll proceed to destory it! This is called as
815 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
816 result = p.output.rstrip()
817 if not result.endswith(self.local):
818 raise VCSException('Repository mismatch')
820 def gotorevisionx(self, rev):
821 if not os.path.exists(self.local):
823 gitsvn_args = ['git', 'svn', 'clone']
824 if ';' in self.remote:
825 remote_split = self.remote.split(';')
826 for i in remote_split[1:]:
827 if i.startswith('trunk='):
828 gitsvn_args.extend(['-T', i[6:]])
829 elif i.startswith('tags='):
830 gitsvn_args.extend(['-t', i[5:]])
831 elif i.startswith('branches='):
832 gitsvn_args.extend(['-b', i[9:]])
833 gitsvn_args.extend([remote_split[0], self.local])
834 p = FDroidPopen(gitsvn_args, output=False)
835 if p.returncode != 0:
836 self.clone_failed = True
837 raise VCSException("Git svn clone failed", p.output)
839 gitsvn_args.extend([self.remote, self.local])
840 p = FDroidPopen(gitsvn_args, output=False)
841 if p.returncode != 0:
842 self.clone_failed = True
843 raise VCSException("Git svn clone failed", p.output)
847 # Discard any working tree changes
848 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
849 if p.returncode != 0:
850 raise VCSException("Git reset failed", p.output)
851 # Remove untracked files now, in case they're tracked in the target
852 # revision (it happens!)
853 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
854 if p.returncode != 0:
855 raise VCSException("Git clean failed", p.output)
856 if not self.refreshed:
857 # Get new commits, branches and tags from repo
858 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
859 if p.returncode != 0:
860 raise VCSException("Git svn fetch failed")
861 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
862 if p.returncode != 0:
863 raise VCSException("Git svn rebase failed", p.output)
864 self.refreshed = True
866 rev = rev or 'master'
868 nospaces_rev = rev.replace(' ', '%20')
869 # Try finding a svn tag
870 for treeish in ['origin/', '']:
871 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
872 if p.returncode == 0:
874 if p.returncode != 0:
875 # No tag found, normal svn rev translation
876 # Translate svn rev into git format
877 rev_split = rev.split('/')
880 for treeish in ['origin/', '']:
881 if len(rev_split) > 1:
882 treeish += rev_split[0]
883 svn_rev = rev_split[1]
886 # if no branch is specified, then assume trunk (i.e. 'master' branch):
890 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
892 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
893 git_rev = p.output.rstrip()
895 if p.returncode == 0 and git_rev:
898 if p.returncode != 0 or not git_rev:
899 # Try a plain git checkout as a last resort
900 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
901 if p.returncode != 0:
902 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
904 # Check out the git rev equivalent to the svn rev
905 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
906 if p.returncode != 0:
907 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
909 # Get rid of any uncontrolled files left behind
910 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
911 if p.returncode != 0:
912 raise VCSException("Git clean failed", p.output)
916 for treeish in ['origin/', '']:
917 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
923 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
924 if p.returncode != 0:
926 return p.output.strip()
934 def gotorevisionx(self, rev):
935 if not os.path.exists(self.local):
936 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
937 if p.returncode != 0:
938 self.clone_failed = True
939 raise VCSException("Hg clone failed", p.output)
941 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
942 if p.returncode != 0:
943 raise VCSException("Hg status failed", p.output)
944 for line in p.output.splitlines():
945 if not line.startswith('? '):
946 raise VCSException("Unexpected output from hg status -uS: " + line)
947 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
948 if not self.refreshed:
949 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
950 if p.returncode != 0:
951 raise VCSException("Hg pull failed", p.output)
952 self.refreshed = True
954 rev = rev or 'default'
957 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
958 if p.returncode != 0:
959 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
960 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
961 # Also delete untracked files, we have to enable purge extension for that:
962 if "'purge' is provided by the following extension" in p.output:
963 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
964 myfile.write("\n[extensions]\nhgext.purge=\n")
965 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
966 if p.returncode != 0:
967 raise VCSException("HG purge failed", p.output)
968 elif p.returncode != 0:
969 raise VCSException("HG purge failed", p.output)
972 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
973 return p.output.splitlines()[1:]
981 def gotorevisionx(self, rev):
982 if not os.path.exists(self.local):
983 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
984 if p.returncode != 0:
985 self.clone_failed = True
986 raise VCSException("Bzr branch failed", p.output)
988 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
989 if p.returncode != 0:
990 raise VCSException("Bzr revert failed", p.output)
991 if not self.refreshed:
992 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
993 if p.returncode != 0:
994 raise VCSException("Bzr update failed", p.output)
995 self.refreshed = True
997 revargs = list(['-r', rev] if rev else [])
998 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
999 if p.returncode != 0:
1000 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1003 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1004 return [tag.split(' ')[0].strip() for tag in
1005 p.output.splitlines()]
1008 def unescape_string(string):
1011 if string[0] == '"' and string[-1] == '"':
1014 return string.replace("\\'", "'")
1017 def retrieve_string(app_dir, string, xmlfiles=None):
1019 if not string.startswith('@string/'):
1020 return unescape_string(string)
1022 if xmlfiles is None:
1025 os.path.join(app_dir, 'res'),
1026 os.path.join(app_dir, 'src', 'main', 'res'),
1028 for r, d, f in os.walk(res_dir):
1029 if os.path.basename(r) == 'values':
1030 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1032 name = string[len('@string/'):]
1034 def element_content(element):
1035 if element.text is None:
1037 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1038 return s.decode('utf-8').strip()
1040 for path in xmlfiles:
1041 if not os.path.isfile(path):
1043 xml = parse_xml(path)
1044 element = xml.find('string[@name="' + name + '"]')
1045 if element is not None:
1046 content = element_content(element)
1047 return retrieve_string(app_dir, content, xmlfiles)
1052 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1053 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1056 def manifest_paths(app_dir, flavours):
1057 '''Return list of existing files that will be used to find the highest vercode'''
1059 possible_manifests = \
1060 [os.path.join(app_dir, 'AndroidManifest.xml'),
1061 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1062 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1063 os.path.join(app_dir, 'build.gradle')]
1065 for flavour in flavours:
1066 if flavour == 'yes':
1068 possible_manifests.append(
1069 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1071 return [path for path in possible_manifests if os.path.isfile(path)]
1074 def fetch_real_name(app_dir, flavours):
1075 '''Retrieve the package name. Returns the name, or None if not found.'''
1076 for path in manifest_paths(app_dir, flavours):
1077 if not has_extension(path, 'xml') or not os.path.isfile(path):
1079 logging.debug("fetch_real_name: Checking manifest at " + path)
1080 xml = parse_xml(path)
1081 app = xml.find('application')
1084 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1086 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1087 result = retrieve_string_singleline(app_dir, label)
1089 result = result.strip()
1094 def get_library_references(root_dir):
1096 proppath = os.path.join(root_dir, 'project.properties')
1097 if not os.path.isfile(proppath):
1099 with open(proppath, 'r', encoding='iso-8859-1') as f:
1101 if not line.startswith('android.library.reference.'):
1103 path = line.split('=')[1].strip()
1104 relpath = os.path.join(root_dir, path)
1105 if not os.path.isdir(relpath):
1107 logging.debug("Found subproject at %s" % path)
1108 libraries.append(path)
1112 def ant_subprojects(root_dir):
1113 subprojects = get_library_references(root_dir)
1114 for subpath in subprojects:
1115 subrelpath = os.path.join(root_dir, subpath)
1116 for p in get_library_references(subrelpath):
1117 relp = os.path.normpath(os.path.join(subpath, p))
1118 if relp not in subprojects:
1119 subprojects.insert(0, relp)
1123 def remove_debuggable_flags(root_dir):
1124 # Remove forced debuggable flags
1125 logging.debug("Removing debuggable flags from %s" % root_dir)
1126 for root, dirs, files in os.walk(root_dir):
1127 if 'AndroidManifest.xml' in files:
1128 regsub_file(r'android:debuggable="[^"]*"',
1130 os.path.join(root, 'AndroidManifest.xml'))
1133 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1134 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1135 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1138 def app_matches_packagename(app, package):
1141 appid = app.UpdateCheckName or app.id
1142 if appid is None or appid == "Ignore":
1144 return appid == package
1147 def parse_androidmanifests(paths, app):
1149 Extract some information from the AndroidManifest.xml at the given path.
1150 Returns (version, vercode, package), any or all of which might be None.
1151 All values returned are strings.
1154 ignoreversions = app.UpdateCheckIgnore
1155 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1158 return (None, None, None)
1166 if not os.path.isfile(path):
1169 logging.debug("Parsing manifest at {0}".format(path))
1174 if has_extension(path, 'gradle'):
1175 with open(path, 'r') as f:
1177 if gradle_comment.match(line):
1179 # Grab first occurence of each to avoid running into
1180 # alternative flavours and builds.
1182 matches = psearch_g(line)
1184 s = matches.group(2)
1185 if app_matches_packagename(app, s):
1188 matches = vnsearch_g(line)
1190 version = matches.group(2)
1192 matches = vcsearch_g(line)
1194 vercode = matches.group(1)
1197 xml = parse_xml(path)
1198 if "package" in xml.attrib:
1199 s = xml.attrib["package"]
1200 if app_matches_packagename(app, s):
1202 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1203 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1204 base_dir = os.path.dirname(path)
1205 version = retrieve_string_singleline(base_dir, version)
1206 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1207 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1208 if string_is_integer(a):
1211 logging.warning("Problem with xml at {0}".format(path))
1213 # Remember package name, may be defined separately from version+vercode
1215 package = max_package
1217 logging.debug("..got package={0}, version={1}, vercode={2}"
1218 .format(package, version, vercode))
1220 # Always grab the package name and version name in case they are not
1221 # together with the highest version code
1222 if max_package is None and package is not None:
1223 max_package = package
1224 if max_version is None and version is not None:
1225 max_version = version
1227 if vercode is not None \
1228 and (max_vercode is None or vercode > max_vercode):
1229 if not ignoresearch or not ignoresearch(version):
1230 if version is not None:
1231 max_version = version
1232 if vercode is not None:
1233 max_vercode = vercode
1234 if package is not None:
1235 max_package = package
1237 max_version = "Ignore"
1239 if max_version is None:
1240 max_version = "Unknown"
1242 if max_package and not is_valid_package_name(max_package):
1243 raise FDroidException("Invalid package name {0}".format(max_package))
1245 return (max_version, max_vercode, max_package)
1248 def is_valid_package_name(name):
1249 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1252 class FDroidException(Exception):
1254 def __init__(self, value, detail=None):
1256 self.detail = detail
1258 def shortened_detail(self):
1259 if len(self.detail) < 16000:
1261 return '[...]\n' + self.detail[-16000:]
1263 def get_wikitext(self):
1264 ret = repr(self.value) + "\n"
1267 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1273 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1277 class VCSException(FDroidException):
1281 class BuildException(FDroidException):
1285 # Get the specified source library.
1286 # Returns the path to it. Normally this is the path to be used when referencing
1287 # it, which may be a subdirectory of the actual project. If you want the base
1288 # directory of the project, pass 'basepath=True'.
1289 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1290 raw=False, prepare=True, preponly=False, refresh=True,
1299 name, ref = spec.split('@')
1301 number, name = name.split(':', 1)
1303 name, subdir = name.split('/', 1)
1305 if name not in fdroidserver.metadata.srclibs:
1306 raise VCSException('srclib ' + name + ' not found.')
1308 srclib = fdroidserver.metadata.srclibs[name]
1310 sdir = os.path.join(srclib_dir, name)
1313 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1314 vcs.srclib = (name, number, sdir)
1316 vcs.gotorevision(ref, refresh)
1323 libdir = os.path.join(sdir, subdir)
1324 elif srclib["Subdir"]:
1325 for subdir in srclib["Subdir"]:
1326 libdir_candidate = os.path.join(sdir, subdir)
1327 if os.path.exists(libdir_candidate):
1328 libdir = libdir_candidate
1334 remove_signing_keys(sdir)
1335 remove_debuggable_flags(sdir)
1339 if srclib["Prepare"]:
1340 cmd = replace_config_vars(srclib["Prepare"], build)
1342 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1343 if p.returncode != 0:
1344 raise BuildException("Error running prepare command for srclib %s"
1350 return (name, number, libdir)
1353 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1356 # Prepare the source code for a particular build
1357 # 'vcs' - the appropriate vcs object for the application
1358 # 'app' - the application details from the metadata
1359 # 'build' - the build details from the metadata
1360 # 'build_dir' - the path to the build directory, usually
1362 # 'srclib_dir' - the path to the source libraries directory, usually
1364 # 'extlib_dir' - the path to the external libraries directory, usually
1366 # Returns the (root, srclibpaths) where:
1367 # 'root' is the root directory, which may be the same as 'build_dir' or may
1368 # be a subdirectory of it.
1369 # 'srclibpaths' is information on the srclibs being used
1370 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1372 # Optionally, the actual app source can be in a subdirectory
1374 root_dir = os.path.join(build_dir, build.subdir)
1376 root_dir = build_dir
1378 # Get a working copy of the right revision
1379 logging.info("Getting source for revision " + build.commit)
1380 vcs.gotorevision(build.commit, refresh)
1382 # Initialise submodules if required
1383 if build.submodules:
1384 logging.info("Initialising submodules")
1385 vcs.initsubmodules()
1387 # Check that a subdir (if we're using one) exists. This has to happen
1388 # after the checkout, since it might not exist elsewhere
1389 if not os.path.exists(root_dir):
1390 raise BuildException('Missing subdir ' + root_dir)
1392 # Run an init command if one is required
1394 cmd = replace_config_vars(build.init, build)
1395 logging.info("Running 'init' commands in %s" % root_dir)
1397 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1398 if p.returncode != 0:
1399 raise BuildException("Error running init command for %s:%s" %
1400 (app.id, build.versionName), p.output)
1402 # Apply patches if any
1404 logging.info("Applying patches")
1405 for patch in build.patch:
1406 patch = patch.strip()
1407 logging.info("Applying " + patch)
1408 patch_path = os.path.join('metadata', app.id, patch)
1409 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1410 if p.returncode != 0:
1411 raise BuildException("Failed to apply patch %s" % patch_path)
1413 # Get required source libraries
1416 logging.info("Collecting source libraries")
1417 for lib in build.srclibs:
1418 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1419 refresh=refresh, build=build))
1421 for name, number, libpath in srclibpaths:
1422 place_srclib(root_dir, int(number) if number else None, libpath)
1424 basesrclib = vcs.getsrclib()
1425 # If one was used for the main source, add that too.
1427 srclibpaths.append(basesrclib)
1429 # Update the local.properties file
1430 localprops = [os.path.join(build_dir, 'local.properties')]
1432 parts = build.subdir.split(os.sep)
1435 cur = os.path.join(cur, d)
1436 localprops += [os.path.join(cur, 'local.properties')]
1437 for path in localprops:
1439 if os.path.isfile(path):
1440 logging.info("Updating local.properties file at %s" % path)
1441 with open(path, 'r', encoding='iso-8859-1') as f:
1445 logging.info("Creating local.properties file at %s" % path)
1446 # Fix old-fashioned 'sdk-location' by copying
1447 # from sdk.dir, if necessary
1449 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1450 re.S | re.M).group(1)
1451 props += "sdk-location=%s\n" % sdkloc
1453 props += "sdk.dir=%s\n" % config['sdk_path']
1454 props += "sdk-location=%s\n" % config['sdk_path']
1455 ndk_path = build.ndk_path()
1456 # if for any reason the path isn't valid or the directory
1457 # doesn't exist, some versions of Gradle will error with a
1458 # cryptic message (even if the NDK is not even necessary).
1459 # https://gitlab.com/fdroid/fdroidserver/issues/171
1460 if ndk_path and os.path.exists(ndk_path):
1462 props += "ndk.dir=%s\n" % ndk_path
1463 props += "ndk-location=%s\n" % ndk_path
1464 # Add java.encoding if necessary
1466 props += "java.encoding=%s\n" % build.encoding
1467 with open(path, 'w', encoding='iso-8859-1') as f:
1471 if build.build_method() == 'gradle':
1472 flavours = build.gradle
1475 n = build.target.split('-')[1]
1476 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1477 r'compileSdkVersion %s' % n,
1478 os.path.join(root_dir, 'build.gradle'))
1480 # Remove forced debuggable flags
1481 remove_debuggable_flags(root_dir)
1483 # Insert version code and number into the manifest if necessary
1484 if build.forceversion:
1485 logging.info("Changing the version name")
1486 for path in manifest_paths(root_dir, flavours):
1487 if not os.path.isfile(path):
1489 if has_extension(path, 'xml'):
1490 regsub_file(r'android:versionName="[^"]*"',
1491 r'android:versionName="%s"' % build.versionName,
1493 elif has_extension(path, 'gradle'):
1494 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1495 r"""\1versionName '%s'""" % build.versionName,
1498 if build.forcevercode:
1499 logging.info("Changing the version code")
1500 for path in manifest_paths(root_dir, flavours):
1501 if not os.path.isfile(path):
1503 if has_extension(path, 'xml'):
1504 regsub_file(r'android:versionCode="[^"]*"',
1505 r'android:versionCode="%s"' % build.versionCode,
1507 elif has_extension(path, 'gradle'):
1508 regsub_file(r'versionCode[ =]+[0-9]+',
1509 r'versionCode %s' % build.versionCode,
1512 # Delete unwanted files
1514 logging.info("Removing specified files")
1515 for part in getpaths(build_dir, build.rm):
1516 dest = os.path.join(build_dir, part)
1517 logging.info("Removing {0}".format(part))
1518 if os.path.lexists(dest):
1519 if os.path.islink(dest):
1520 FDroidPopen(['unlink', dest], output=False)
1522 FDroidPopen(['rm', '-rf', dest], output=False)
1524 logging.info("...but it didn't exist")
1526 remove_signing_keys(build_dir)
1528 # Add required external libraries
1530 logging.info("Collecting prebuilt libraries")
1531 libsdir = os.path.join(root_dir, 'libs')
1532 if not os.path.exists(libsdir):
1534 for lib in build.extlibs:
1536 logging.info("...installing extlib {0}".format(lib))
1537 libf = os.path.basename(lib)
1538 libsrc = os.path.join(extlib_dir, lib)
1539 if not os.path.exists(libsrc):
1540 raise BuildException("Missing extlib file {0}".format(libsrc))
1541 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1543 # Run a pre-build command if one is required
1545 logging.info("Running 'prebuild' commands in %s" % root_dir)
1547 cmd = replace_config_vars(build.prebuild, build)
1549 # Substitute source library paths into prebuild commands
1550 for name, number, libpath in srclibpaths:
1551 libpath = os.path.relpath(libpath, root_dir)
1552 cmd = cmd.replace('$$' + name + '$$', libpath)
1554 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1555 if p.returncode != 0:
1556 raise BuildException("Error running prebuild command for %s:%s" %
1557 (app.id, build.versionName), p.output)
1559 # Generate (or update) the ant build file, build.xml...
1560 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1561 parms = ['android', 'update', 'lib-project']
1562 lparms = ['android', 'update', 'project']
1565 parms += ['-t', build.target]
1566 lparms += ['-t', build.target]
1567 if build.androidupdate:
1568 update_dirs = build.androidupdate
1570 update_dirs = ant_subprojects(root_dir) + ['.']
1572 for d in update_dirs:
1573 subdir = os.path.join(root_dir, d)
1575 logging.debug("Updating main project")
1576 cmd = parms + ['-p', d]
1578 logging.debug("Updating subproject %s" % d)
1579 cmd = lparms + ['-p', d]
1580 p = SdkToolsPopen(cmd, cwd=root_dir)
1581 # Check to see whether an error was returned without a proper exit
1582 # code (this is the case for the 'no target set or target invalid'
1584 if p.returncode != 0 or p.output.startswith("Error: "):
1585 raise BuildException("Failed to update project at %s" % d, p.output)
1586 # Clean update dirs via ant
1588 logging.info("Cleaning subproject %s" % d)
1589 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1591 return (root_dir, srclibpaths)
1594 # Extend via globbing the paths from a field and return them as a map from
1595 # original path to resulting paths
1596 def getpaths_map(build_dir, globpaths):
1600 full_path = os.path.join(build_dir, p)
1601 full_path = os.path.normpath(full_path)
1602 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1604 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1608 # Extend via globbing the paths from a field and return them as a set
1609 def getpaths(build_dir, globpaths):
1610 paths_map = getpaths_map(build_dir, globpaths)
1612 for k, v in paths_map.items():
1619 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1625 self.path = os.path.join('stats', 'known_apks.txt')
1627 if os.path.isfile(self.path):
1628 with open(self.path, 'r', encoding='utf8') as f:
1630 t = line.rstrip().split(' ')
1632 self.apks[t[0]] = (t[1], None)
1634 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1635 self.changed = False
1637 def writeifchanged(self):
1638 if not self.changed:
1641 if not os.path.exists('stats'):
1645 for apk, app in self.apks.items():
1647 line = apk + ' ' + appid
1649 line += ' ' + added.strftime('%Y-%m-%d')
1652 with open(self.path, 'w', encoding='utf8') as f:
1653 for line in sorted(lst, key=natural_key):
1654 f.write(line + '\n')
1656 def recordapk(self, apk, app, default_date=None):
1658 Record an apk (if it's new, otherwise does nothing)
1659 Returns the date it was added as a datetime instance
1661 if apk not in self.apks:
1662 if default_date is None:
1663 default_date = datetime.utcnow()
1664 self.apks[apk] = (app, default_date)
1666 _, added = self.apks[apk]
1669 # Look up information - given the 'apkname', returns (app id, date added/None).
1670 # Or returns None for an unknown apk.
1671 def getapp(self, apkname):
1672 if apkname in self.apks:
1673 return self.apks[apkname]
1676 # Get the most recent 'num' apps added to the repo, as a list of package ids
1677 # with the most recent first.
1678 def getlatest(self, num):
1680 for apk, app in self.apks.items():
1684 if apps[appid] > added:
1688 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1689 lst = [app for app, _ in sortedapps]
1694 def get_file_extension(filename):
1695 """get the normalized file extension, can be blank string but never None"""
1697 return os.path.splitext(filename)[1].lower()[1:]
1700 def isApkAndDebuggable(apkfile, config):
1701 """Returns True if the given file is an APK and is debuggable
1703 :param apkfile: full path to the apk to check"""
1705 if get_file_extension(apkfile) != 'apk':
1708 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1710 if p.returncode != 0:
1711 logging.critical("Failed to get apk manifest information")
1713 for line in p.output.splitlines():
1714 if 'android:debuggable' in line and not line.endswith('0x0'):
1721 self.returncode = None
1725 def SdkToolsPopen(commands, cwd=None, output=True):
1727 if cmd not in config:
1728 config[cmd] = find_sdk_tools_cmd(commands[0])
1729 abscmd = config[cmd]
1731 logging.critical("Could not find '%s' on your system" % cmd)
1734 test_aapt_version(config['aapt'])
1735 return FDroidPopen([abscmd] + commands[1:],
1736 cwd=cwd, output=output)
1739 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1741 Run a command and capture the possibly huge output as bytes.
1743 :param commands: command and argument list like in subprocess.Popen
1744 :param cwd: optionally specifies a working directory
1745 :returns: A PopenResult.
1750 set_FDroidPopen_env()
1753 cwd = os.path.normpath(cwd)
1754 logging.debug("Directory: %s" % cwd)
1755 logging.debug("> %s" % ' '.join(commands))
1757 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1758 result = PopenResult()
1761 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1762 stdout=subprocess.PIPE, stderr=stderr_param)
1763 except OSError as e:
1764 raise BuildException("OSError while trying to execute " +
1765 ' '.join(commands) + ': ' + str(e))
1767 if not stderr_to_stdout and options.verbose:
1768 stderr_queue = Queue()
1769 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1771 while not stderr_reader.eof():
1772 while not stderr_queue.empty():
1773 line = stderr_queue.get()
1774 sys.stderr.buffer.write(line)
1779 stdout_queue = Queue()
1780 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1783 # Check the queue for output (until there is no more to get)
1784 while not stdout_reader.eof():
1785 while not stdout_queue.empty():
1786 line = stdout_queue.get()
1787 if output and options.verbose:
1788 # Output directly to console
1789 sys.stderr.buffer.write(line)
1795 result.returncode = p.wait()
1796 result.output = buf.getvalue()
1801 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1803 Run a command and capture the possibly huge output as a str.
1805 :param commands: command and argument list like in subprocess.Popen
1806 :param cwd: optionally specifies a working directory
1807 :returns: A PopenResult.
1809 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1810 result.output = result.output.decode('utf-8', 'ignore')
1814 gradle_comment = re.compile(r'[ ]*//')
1815 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1816 gradle_line_matches = [
1817 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1818 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1819 re.compile(r'.*\.readLine\(.*'),
1823 def remove_signing_keys(build_dir):
1824 for root, dirs, files in os.walk(build_dir):
1825 if 'build.gradle' in files:
1826 path = os.path.join(root, 'build.gradle')
1828 with open(path, "r", encoding='utf8') as o:
1829 lines = o.readlines()
1835 with open(path, "w", encoding='utf8') as o:
1836 while i < len(lines):
1839 while line.endswith('\\\n'):
1840 line = line.rstrip('\\\n') + lines[i]
1843 if gradle_comment.match(line):
1848 opened += line.count('{')
1849 opened -= line.count('}')
1852 if gradle_signing_configs.match(line):
1857 if any(s.match(line) for s in gradle_line_matches):
1865 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1868 'project.properties',
1870 'default.properties',
1871 'ant.properties', ]:
1872 if propfile in files:
1873 path = os.path.join(root, propfile)
1875 with open(path, "r", encoding='iso-8859-1') as o:
1876 lines = o.readlines()
1880 with open(path, "w", encoding='iso-8859-1') as o:
1882 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1889 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1892 def set_FDroidPopen_env(build=None):
1894 set up the environment variables for the build environment
1896 There is only a weak standard, the variables used by gradle, so also set
1897 up the most commonly used environment variables for SDK and NDK. Also, if
1898 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1900 global env, orig_path
1904 orig_path = env['PATH']
1905 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1906 env[n] = config['sdk_path']
1907 for k, v in config['java_paths'].items():
1908 env['JAVA%s_HOME' % k] = v
1910 missinglocale = True
1911 for k, v in env.items():
1912 if k == 'LANG' and v != 'C':
1913 missinglocale = False
1915 missinglocale = False
1917 env['LANG'] = 'en_US.UTF-8'
1919 if build is not None:
1920 path = build.ndk_path()
1921 paths = orig_path.split(os.pathsep)
1922 if path not in paths:
1923 paths = [path] + paths
1924 env['PATH'] = os.pathsep.join(paths)
1925 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1926 env[n] = build.ndk_path()
1929 def replace_build_vars(cmd, build):
1930 cmd = cmd.replace('$$COMMIT$$', build.commit)
1931 cmd = cmd.replace('$$VERSION$$', build.versionName)
1932 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1936 def replace_config_vars(cmd, build):
1937 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1938 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1939 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1940 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1941 if build is not None:
1942 cmd = replace_build_vars(cmd, build)
1946 def place_srclib(root_dir, number, libpath):
1949 relpath = os.path.relpath(libpath, root_dir)
1950 proppath = os.path.join(root_dir, 'project.properties')
1953 if os.path.isfile(proppath):
1954 with open(proppath, "r", encoding='iso-8859-1') as o:
1955 lines = o.readlines()
1957 with open(proppath, "w", encoding='iso-8859-1') as o:
1960 if line.startswith('android.library.reference.%d=' % number):
1961 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1966 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1969 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1972 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1973 """Verify that two apks are the same
1975 One of the inputs is signed, the other is unsigned. The signature metadata
1976 is transferred from the signed to the unsigned apk, and then jarsigner is
1977 used to verify that the signature from the signed apk is also varlid for
1978 the unsigned one. If the APK given as unsigned actually does have a
1979 signature, it will be stripped out and ignored.
1981 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1982 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1983 into AndroidManifest.xml, but that makes the build not reproducible. So
1984 instead they are included as separate files in the APK's META-INF/ folder.
1985 If those files exist in the signed APK, they will be part of the signature
1986 and need to also be included in the unsigned APK for it to validate.
1988 :param signed_apk: Path to a signed apk file
1989 :param unsigned_apk: Path to an unsigned apk file expected to match it
1990 :param tmp_dir: Path to directory for temporary files
1991 :returns: None if the verification is successful, otherwise a string
1992 describing what went wrong.
1995 signed = ZipFile(signed_apk, 'r')
1996 meta_inf_files = ['META-INF/MANIFEST.MF']
1997 for f in signed.namelist():
1998 if apk_sigfile.match(f) \
1999 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2000 meta_inf_files.append(f)
2001 if len(meta_inf_files) < 3:
2002 return "Signature files missing from {0}".format(signed_apk)
2004 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2005 unsigned = ZipFile(unsigned_apk, 'r')
2006 # only read the signature from the signed APK, everything else from unsigned
2007 with ZipFile(tmp_apk, 'w') as tmp:
2008 for filename in meta_inf_files:
2009 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2010 for info in unsigned.infolist():
2011 if info.filename in meta_inf_files:
2012 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2014 if info.filename in tmp.namelist():
2015 return "duplicate filename found: " + info.filename
2016 tmp.writestr(info, unsigned.read(info.filename))
2020 verified = verify_apk_signature(tmp_apk)
2023 logging.info("...NOT verified - {0}".format(tmp_apk))
2024 return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2026 logging.info("...successfully verified")
2030 def verify_apk_signature(apk):
2031 """verify the signature on an APK
2033 Try to use apksigner whenever possible since jarsigner is very
2034 shitty: unsigned APKs pass as "verified"! So this has to turn on
2035 -strict then check for result 4.
2038 if set_command_in_config('apksigner'):
2039 return subprocess.call([config['apksigner'], 'verify', 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)
2170 write_password_file("keystorepass", localconfig['keystorepass'])
2171 write_password_file("keypass", localconfig['keypass'])
2172 p = FDroidPopen([config['keytool'], '-genkey',
2173 '-keystore', localconfig['keystore'],
2174 '-alias', localconfig['repo_keyalias'],
2175 '-keyalg', 'RSA', '-keysize', '4096',
2176 '-sigalg', 'SHA256withRSA',
2177 '-validity', '10000',
2178 '-storepass:file', config['keystorepassfile'],
2179 '-keypass:file', config['keypassfile'],
2180 '-dname', localconfig['keydname']])
2181 # TODO keypass should be sent via stdin
2182 if p.returncode != 0:
2183 raise BuildException("Failed to generate key", p.output)
2184 os.chmod(localconfig['keystore'], 0o0600)
2185 if not options.quiet:
2186 # now show the lovely key that was just generated
2187 p = FDroidPopen([config['keytool'], '-list', '-v',
2188 '-keystore', localconfig['keystore'],
2189 '-alias', localconfig['repo_keyalias'],
2190 '-storepass:file', config['keystorepassfile']])
2191 logging.info(p.output.strip() + '\n\n')
2192 # get the public key
2193 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2194 '-keystore', localconfig['keystore'],
2195 '-alias', localconfig['repo_keyalias'],
2196 '-storepass:file', config['keystorepassfile']]
2197 + config['smartcardoptions'],
2198 output=False, stderr_to_stdout=False)
2199 if p.returncode != 0 or len(p.output) < 20:
2200 raise BuildException("Failed to get public key", p.output)
2202 fingerprint = get_cert_fingerprint(pubkey)
2203 return hexlify(pubkey), fingerprint
2206 def get_cert_fingerprint(pubkey):
2208 Generate a certificate fingerprint the same way keytool does it
2209 (but with slightly different formatting)
2211 digest = hashlib.sha256(pubkey).digest()
2212 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2213 return " ".join(ret)
2216 def write_to_config(thisconfig, key, value=None, config_file=None):
2217 '''write a key/value to the local config.py
2219 NOTE: only supports writing string variables.
2221 :param thisconfig: config dictionary
2222 :param key: variable name in config.py to be overwritten/added
2223 :param value: optional value to be written, instead of fetched
2224 from 'thisconfig' dictionary.
2227 origkey = key + '_orig'
2228 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2229 cfg = config_file if config_file else 'config.py'
2232 with open(cfg, 'r', encoding="utf-8") as f:
2233 lines = f.readlines()
2235 # make sure the file ends with a carraige return
2237 if not lines[-1].endswith('\n'):
2240 # regex for finding and replacing python string variable
2241 # definitions/initializations
2242 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2243 repl = key + ' = "' + value + '"'
2244 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2245 repl2 = key + " = '" + value + "'"
2247 # If we replaced this line once, we make sure won't be a
2248 # second instance of this line for this key in the document.
2251 with open(cfg, 'w', encoding="utf-8") as f:
2253 if pattern.match(line) or pattern2.match(line):
2255 line = pattern.sub(repl, line)
2256 line = pattern2.sub(repl2, line)
2267 def parse_xml(path):
2268 return XMLElementTree.parse(path).getroot()
2271 def string_is_integer(string):
2279 def get_per_app_repos():
2280 '''per-app repos are dirs named with the packageName of a single app'''
2282 # Android packageNames are Java packages, they may contain uppercase or
2283 # lowercase letters ('A' through 'Z'), numbers, and underscores
2284 # ('_'). However, individual package name parts may only start with
2285 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2286 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2289 for root, dirs, files in os.walk(os.getcwd()):
2291 print('checking', root, 'for', d)
2292 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2293 # standard parts of an fdroid repo, so never packageNames
2296 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2302 def is_repo_file(filename):
2303 '''Whether the file in a repo is a build product to be delivered to users'''
2304 return os.path.isfile(filename) \
2305 and not filename.endswith('.asc') \
2306 and not filename.endswith('.sig') \
2307 and os.path.basename(filename) not in [
2309 'index_unsigned.jar',