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 # A signature block file with a .DSA, .RSA, or .EC extension
50 CERT_PATH_REGEX = re.compile(r'^META-INF/.*\.(DSA|EC|RSA)$')
52 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
61 'sdk_path': "$ANDROID_HOME",
66 'r12b': "$ANDROID_NDK",
71 'build_tools': "25.0.2",
72 'force_build_tools': False,
77 'accepted_formats': ['txt', 'yml'],
78 'sync_from_local_copy_dir': False,
79 'per_app_repos': False,
80 'make_current_version_link': True,
81 'current_version_name_source': 'Name',
82 'update_stats': False,
86 'stats_to_carbon': False,
88 'build_server_always': False,
89 'keystore': 'keystore.jks',
90 'smartcardoptions': [],
96 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
97 'repo_name': "My First FDroid Repo Demo",
98 'repo_icon': "fdroid-icon.png",
99 'repo_description': '''
100 This is a repository of apps to be used with FDroid. Applications in this
101 repository are either official binaries built by the original application
102 developers, or are binaries built from source by the admin of f-droid.org
103 using the tools on https://gitlab.com/u/fdroid.
109 def setup_global_opts(parser):
110 parser.add_argument("-v", "--verbose", action="store_true", default=False,
111 help="Spew out even more information than normal")
112 parser.add_argument("-q", "--quiet", action="store_true", default=False,
113 help="Restrict output to warnings and errors")
116 def fill_config_defaults(thisconfig):
117 for k, v in default_config.items():
118 if k not in thisconfig:
121 # Expand paths (~users and $vars)
122 def expand_path(path):
126 path = os.path.expanduser(path)
127 path = os.path.expandvars(path)
132 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
137 thisconfig[k + '_orig'] = v
139 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
140 if thisconfig['java_paths'] is None:
141 thisconfig['java_paths'] = dict()
143 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
144 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
145 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
146 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
147 if os.getenv('JAVA_HOME') is not None:
148 pathlist.append(os.getenv('JAVA_HOME'))
149 if os.getenv('PROGRAMFILES') is not None:
150 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
151 for d in sorted(pathlist):
152 if os.path.islink(d):
154 j = os.path.basename(d)
155 # the last one found will be the canonical one, so order appropriately
157 r'^1\.([6-9])\.0\.jdk$', # OSX
158 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
159 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
160 r'^jdk([6-9])-openjdk$', # Arch
161 r'^java-([6-9])-openjdk$', # Arch
162 r'^java-([6-9])-jdk$', # Arch (oracle)
163 r'^java-1\.([6-9])\.0-.*$', # RedHat
164 r'^java-([6-9])-oracle$', # Debian WebUpd8
165 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
166 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
168 m = re.match(regex, j)
171 for p in [d, os.path.join(d, 'Contents', 'Home')]:
172 if os.path.exists(os.path.join(p, 'bin', 'javac')):
173 thisconfig['java_paths'][m.group(1)] = p
175 for java_version in ('7', '8', '9'):
176 if java_version not in thisconfig['java_paths']:
178 java_home = thisconfig['java_paths'][java_version]
179 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
180 if os.path.exists(jarsigner):
181 thisconfig['jarsigner'] = jarsigner
182 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
183 break # Java7 is preferred, so quit if found
185 for k in ['ndk_paths', 'java_paths']:
191 thisconfig[k][k2] = exp
192 thisconfig[k][k2 + '_orig'] = v
195 def regsub_file(pattern, repl, path):
196 with open(path, 'rb') as f:
198 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
199 with open(path, 'wb') as f:
203 def read_config(opts, config_file='config.py'):
204 """Read the repository config
206 The config is read from config_file, which is in the current
207 directory when any of the repo management commands are used. If
208 there is a local metadata file in the git repo, then config.py is
209 not required, just use defaults.
212 global config, options
214 if config is not None:
221 if os.path.isfile(config_file):
222 logging.debug("Reading %s" % config_file)
223 with io.open(config_file, "rb") as f:
224 code = compile(f.read(), config_file, 'exec')
225 exec(code, None, config)
226 elif len(get_local_metadata_files()) == 0:
227 logging.critical("Missing config file - is this a repo directory?")
230 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
232 if not type(config[k]) in (str, list, tuple):
233 logging.warn('"' + k + '" will be in random order!'
234 + ' Use () or [] brackets if order is important!')
236 # smartcardoptions must be a list since its command line args for Popen
237 if 'smartcardoptions' in config:
238 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
239 elif 'keystore' in config and config['keystore'] == 'NONE':
240 # keystore='NONE' means use smartcard, these are required defaults
241 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
242 'SunPKCS11-OpenSC', '-providerClass',
243 'sun.security.pkcs11.SunPKCS11',
244 '-providerArg', 'opensc-fdroid.cfg']
246 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
247 st = os.stat(config_file)
248 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
249 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
251 fill_config_defaults(config)
253 for k in ["keystorepass", "keypass"]:
255 write_password_file(k)
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 write_password_file(pwtype, password=None):
382 writes out passwords to a protected file instead of passing passwords as
383 command line argments
385 filename = '.fdroid.' + pwtype + '.txt'
386 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
388 os.write(fd, config[pwtype].encode('utf-8'))
390 os.write(fd, password.encode('utf-8'))
392 config[pwtype + 'file'] = filename
395 def get_local_metadata_files():
396 '''get any metadata files local to an app's source repo
398 This tries to ignore anything that does not count as app metdata,
399 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
402 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
405 def read_pkg_args(args, allow_vercodes=False):
407 Given the arguments in the form of multiple appid:[vc] strings, this returns
408 a dictionary with the set of vercodes specified for each package.
416 if allow_vercodes and ':' in p:
417 package, vercode = p.split(':')
419 package, vercode = p, None
420 if package not in vercodes:
421 vercodes[package] = [vercode] if vercode else []
423 elif vercode and vercode not in vercodes[package]:
424 vercodes[package] += [vercode] if vercode else []
429 def read_app_args(args, allapps, allow_vercodes=False):
431 On top of what read_pkg_args does, this returns the whole app metadata, but
432 limiting the builds list to the builds matching the vercodes specified.
435 vercodes = read_pkg_args(args, allow_vercodes)
441 for appid, app in allapps.items():
442 if appid in vercodes:
445 if len(apps) != len(vercodes):
448 logging.critical("No such package: %s" % p)
449 raise FDroidException("Found invalid app ids in arguments")
451 raise FDroidException("No packages specified")
454 for appid, app in apps.items():
458 app.builds = [b for b in app.builds if b.versionCode in vc]
459 if len(app.builds) != len(vercodes[appid]):
461 allvcs = [b.versionCode for b in app.builds]
462 for v in vercodes[appid]:
464 logging.critical("No such vercode %s for app %s" % (v, appid))
467 raise FDroidException("Found invalid vercodes for some apps")
472 def get_extension(filename):
473 base, ext = os.path.splitext(filename)
476 return base, ext.lower()[1:]
479 def has_extension(filename, ext):
480 _, f_ext = get_extension(filename)
484 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
487 def clean_description(description):
488 'Remove unneeded newlines and spaces from a block of description text'
490 # this is split up by paragraph to make removing the newlines easier
491 for paragraph in re.split(r'\n\n', description):
492 paragraph = re.sub('\r', '', paragraph)
493 paragraph = re.sub('\n', ' ', paragraph)
494 paragraph = re.sub(' {2,}', ' ', paragraph)
495 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
496 returnstring += paragraph + '\n\n'
497 return returnstring.rstrip('\n')
500 def publishednameinfo(filename):
501 filename = os.path.basename(filename)
502 m = publish_name_regex.match(filename)
504 result = (m.group(1), m.group(2))
505 except AttributeError:
506 raise FDroidException("Invalid name for published file: %s" % filename)
510 def get_release_filename(app, build):
512 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
514 return "%s_%s.apk" % (app.id, build.versionCode)
517 def get_toolsversion_logname(app, build):
518 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
521 def getsrcname(app, build):
522 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
534 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
537 def get_build_dir(app):
538 '''get the dir that this app will be built in'''
540 if app.RepoType == 'srclib':
541 return os.path.join('build', 'srclib', app.Repo)
543 return os.path.join('build', app.id)
547 '''checkout code from VCS and return instance of vcs and the build dir'''
548 build_dir = get_build_dir(app)
550 # Set up vcs interface and make sure we have the latest code...
551 logging.debug("Getting {0} vcs interface for {1}"
552 .format(app.RepoType, app.Repo))
553 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
557 vcs = getvcs(app.RepoType, remote, build_dir)
559 return vcs, build_dir
562 def getvcs(vcstype, remote, local):
564 return vcs_git(remote, local)
565 if vcstype == 'git-svn':
566 return vcs_gitsvn(remote, local)
568 return vcs_hg(remote, local)
570 return vcs_bzr(remote, local)
571 if vcstype == 'srclib':
572 if local != os.path.join('build', 'srclib', remote):
573 raise VCSException("Error: srclib paths are hard-coded!")
574 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
576 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
577 raise VCSException("Invalid vcs type " + vcstype)
580 def getsrclibvcs(name):
581 if name not in fdroidserver.metadata.srclibs:
582 raise VCSException("Missing srclib " + name)
583 return fdroidserver.metadata.srclibs[name]['Repo Type']
588 def __init__(self, remote, local):
590 # svn, git-svn and bzr may require auth
592 if self.repotype() in ('git-svn', 'bzr'):
594 if self.repotype == 'git-svn':
595 raise VCSException("Authentication is not supported for git-svn")
596 self.username, remote = remote.split('@')
597 if ':' not in self.username:
598 raise VCSException("Password required with username")
599 self.username, self.password = self.username.split(':')
603 self.clone_failed = False
604 self.refreshed = False
610 # Take the local repository to a clean version of the given revision, which
611 # is specificed in the VCS's native format. Beforehand, the repository can
612 # be dirty, or even non-existent. If the repository does already exist
613 # locally, it will be updated from the origin, but only once in the
614 # lifetime of the vcs object.
615 # None is acceptable for 'rev' if you know you are cloning a clean copy of
616 # the repo - otherwise it must specify a valid revision.
617 def gotorevision(self, rev, refresh=True):
619 if self.clone_failed:
620 raise VCSException("Downloading the repository already failed once, not trying again.")
622 # The .fdroidvcs-id file for a repo tells us what VCS type
623 # and remote that directory was created from, allowing us to drop it
624 # automatically if either of those things changes.
625 fdpath = os.path.join(self.local, '..',
626 '.fdroidvcs-' + os.path.basename(self.local))
627 fdpath = os.path.normpath(fdpath)
628 cdata = self.repotype() + ' ' + self.remote
631 if os.path.exists(self.local):
632 if os.path.exists(fdpath):
633 with open(fdpath, 'r') as f:
634 fsdata = f.read().strip()
639 logging.info("Repository details for %s changed - deleting" % (
643 logging.info("Repository details for %s missing - deleting" % (
646 shutil.rmtree(self.local)
650 self.refreshed = True
653 self.gotorevisionx(rev)
654 except FDroidException as e:
657 # If necessary, write the .fdroidvcs file.
658 if writeback and not self.clone_failed:
659 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
660 with open(fdpath, 'w+') as f:
666 # Derived classes need to implement this. It's called once basic checking
667 # has been performend.
668 def gotorevisionx(self, rev):
669 raise VCSException("This VCS type doesn't define gotorevisionx")
671 # Initialise and update submodules
672 def initsubmodules(self):
673 raise VCSException('Submodules not supported for this vcs type')
675 # Get a list of all known tags
677 if not self._gettags:
678 raise VCSException('gettags not supported for this vcs type')
680 for tag in self._gettags():
681 if re.match('[-A-Za-z0-9_. /]+$', tag):
685 # Get a list of all the known tags, sorted from newest to oldest
686 def latesttags(self):
687 raise VCSException('latesttags not supported for this vcs type')
689 # Get current commit reference (hash, revision, etc)
691 raise VCSException('getref not supported for this vcs type')
693 # Returns the srclib (name, path) used in setting up the current
704 # If the local directory exists, but is somehow not a git repository, git
705 # will traverse up the directory tree until it finds one that is (i.e.
706 # fdroidserver) and then we'll proceed to destroy it! This is called as
709 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
710 result = p.output.rstrip()
711 if not result.endswith(self.local):
712 raise VCSException('Repository mismatch')
714 def gotorevisionx(self, rev):
715 if not os.path.exists(self.local):
717 p = FDroidPopen(['git', 'clone', self.remote, self.local])
718 if p.returncode != 0:
719 self.clone_failed = True
720 raise VCSException("Git clone failed", p.output)
724 # Discard any working tree changes
725 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
726 'git', 'reset', '--hard'], cwd=self.local, output=False)
727 if p.returncode != 0:
728 raise VCSException("Git reset failed", p.output)
729 # Remove untracked files now, in case they're tracked in the target
730 # revision (it happens!)
731 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
732 'git', 'clean', '-dffx'], cwd=self.local, output=False)
733 if p.returncode != 0:
734 raise VCSException("Git clean failed", p.output)
735 if not self.refreshed:
736 # Get latest commits and tags from remote
737 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
738 if p.returncode != 0:
739 raise VCSException("Git fetch failed", p.output)
740 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
741 if p.returncode != 0:
742 raise VCSException("Git fetch failed", p.output)
743 # Recreate origin/HEAD as git clone would do it, in case it disappeared
744 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
745 if p.returncode != 0:
746 lines = p.output.splitlines()
747 if 'Multiple remote HEAD branches' not in lines[0]:
748 raise VCSException("Git remote set-head failed", p.output)
749 branch = lines[1].split(' ')[-1]
750 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
751 if p2.returncode != 0:
752 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
753 self.refreshed = True
754 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
755 # a github repo. Most of the time this is the same as origin/master.
756 rev = rev or 'origin/HEAD'
757 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
758 if p.returncode != 0:
759 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
760 # Get rid of any uncontrolled files left behind
761 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
762 if p.returncode != 0:
763 raise VCSException("Git clean failed", p.output)
765 def initsubmodules(self):
767 submfile = os.path.join(self.local, '.gitmodules')
768 if not os.path.isfile(submfile):
769 raise VCSException("No git submodules available")
771 # fix submodules not accessible without an account and public key auth
772 with open(submfile, 'r') as f:
773 lines = f.readlines()
774 with open(submfile, 'w') as f:
776 if 'git@github.com' in line:
777 line = line.replace('git@github.com:', 'https://github.com/')
778 if 'git@gitlab.com' in line:
779 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
782 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
783 if p.returncode != 0:
784 raise VCSException("Git submodule sync failed", p.output)
785 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
786 if p.returncode != 0:
787 raise VCSException("Git submodule update failed", p.output)
791 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
792 return p.output.splitlines()
794 tag_format = re.compile(r'tag: ([^),]*)')
796 def latesttags(self):
798 p = FDroidPopen(['git', 'log', '--tags',
799 '--simplify-by-decoration', '--pretty=format:%d'],
800 cwd=self.local, output=False)
802 for line in p.output.splitlines():
803 for tag in self.tag_format.findall(line):
808 class vcs_gitsvn(vcs):
813 # If the local directory exists, but is somehow not a git repository, git
814 # will traverse up the directory tree until it finds one that is (i.e.
815 # fdroidserver) and then we'll proceed to destory it! This is called as
818 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
819 result = p.output.rstrip()
820 if not result.endswith(self.local):
821 raise VCSException('Repository mismatch')
823 def gotorevisionx(self, rev):
824 if not os.path.exists(self.local):
826 gitsvn_args = ['git', 'svn', 'clone']
827 if ';' in self.remote:
828 remote_split = self.remote.split(';')
829 for i in remote_split[1:]:
830 if i.startswith('trunk='):
831 gitsvn_args.extend(['-T', i[6:]])
832 elif i.startswith('tags='):
833 gitsvn_args.extend(['-t', i[5:]])
834 elif i.startswith('branches='):
835 gitsvn_args.extend(['-b', i[9:]])
836 gitsvn_args.extend([remote_split[0], self.local])
837 p = FDroidPopen(gitsvn_args, output=False)
838 if p.returncode != 0:
839 self.clone_failed = True
840 raise VCSException("Git svn clone failed", p.output)
842 gitsvn_args.extend([self.remote, self.local])
843 p = FDroidPopen(gitsvn_args, output=False)
844 if p.returncode != 0:
845 self.clone_failed = True
846 raise VCSException("Git svn clone failed", p.output)
850 # Discard any working tree changes
851 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
852 if p.returncode != 0:
853 raise VCSException("Git reset failed", p.output)
854 # Remove untracked files now, in case they're tracked in the target
855 # revision (it happens!)
856 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
857 if p.returncode != 0:
858 raise VCSException("Git clean failed", p.output)
859 if not self.refreshed:
860 # Get new commits, branches and tags from repo
861 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
862 if p.returncode != 0:
863 raise VCSException("Git svn fetch failed")
864 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
865 if p.returncode != 0:
866 raise VCSException("Git svn rebase failed", p.output)
867 self.refreshed = True
869 rev = rev or 'master'
871 nospaces_rev = rev.replace(' ', '%20')
872 # Try finding a svn tag
873 for treeish in ['origin/', '']:
874 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
875 if p.returncode == 0:
877 if p.returncode != 0:
878 # No tag found, normal svn rev translation
879 # Translate svn rev into git format
880 rev_split = rev.split('/')
883 for treeish in ['origin/', '']:
884 if len(rev_split) > 1:
885 treeish += rev_split[0]
886 svn_rev = rev_split[1]
889 # if no branch is specified, then assume trunk (i.e. 'master' branch):
893 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
895 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
896 git_rev = p.output.rstrip()
898 if p.returncode == 0 and git_rev:
901 if p.returncode != 0 or not git_rev:
902 # Try a plain git checkout as a last resort
903 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
904 if p.returncode != 0:
905 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
907 # Check out the git rev equivalent to the svn rev
908 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
909 if p.returncode != 0:
910 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
912 # Get rid of any uncontrolled files left behind
913 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
914 if p.returncode != 0:
915 raise VCSException("Git clean failed", p.output)
919 for treeish in ['origin/', '']:
920 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
926 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
927 if p.returncode != 0:
929 return p.output.strip()
937 def gotorevisionx(self, rev):
938 if not os.path.exists(self.local):
939 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
940 if p.returncode != 0:
941 self.clone_failed = True
942 raise VCSException("Hg clone failed", p.output)
944 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
945 if p.returncode != 0:
946 raise VCSException("Hg status failed", p.output)
947 for line in p.output.splitlines():
948 if not line.startswith('? '):
949 raise VCSException("Unexpected output from hg status -uS: " + line)
950 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
951 if not self.refreshed:
952 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
953 if p.returncode != 0:
954 raise VCSException("Hg pull failed", p.output)
955 self.refreshed = True
957 rev = rev or 'default'
960 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
961 if p.returncode != 0:
962 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
963 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
964 # Also delete untracked files, we have to enable purge extension for that:
965 if "'purge' is provided by the following extension" in p.output:
966 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
967 myfile.write("\n[extensions]\nhgext.purge=\n")
968 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
969 if p.returncode != 0:
970 raise VCSException("HG purge failed", p.output)
971 elif p.returncode != 0:
972 raise VCSException("HG purge failed", p.output)
975 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
976 return p.output.splitlines()[1:]
984 def gotorevisionx(self, rev):
985 if not os.path.exists(self.local):
986 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
987 if p.returncode != 0:
988 self.clone_failed = True
989 raise VCSException("Bzr branch failed", p.output)
991 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
992 if p.returncode != 0:
993 raise VCSException("Bzr revert failed", p.output)
994 if not self.refreshed:
995 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
996 if p.returncode != 0:
997 raise VCSException("Bzr update failed", p.output)
998 self.refreshed = True
1000 revargs = list(['-r', rev] if rev else [])
1001 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1002 if p.returncode != 0:
1003 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1006 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1007 return [tag.split(' ')[0].strip() for tag in
1008 p.output.splitlines()]
1011 def unescape_string(string):
1014 if string[0] == '"' and string[-1] == '"':
1017 return string.replace("\\'", "'")
1020 def retrieve_string(app_dir, string, xmlfiles=None):
1022 if not string.startswith('@string/'):
1023 return unescape_string(string)
1025 if xmlfiles is None:
1028 os.path.join(app_dir, 'res'),
1029 os.path.join(app_dir, 'src', 'main', 'res'),
1031 for r, d, f in os.walk(res_dir):
1032 if os.path.basename(r) == 'values':
1033 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1035 name = string[len('@string/'):]
1037 def element_content(element):
1038 if element.text is None:
1040 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1041 return s.decode('utf-8').strip()
1043 for path in xmlfiles:
1044 if not os.path.isfile(path):
1046 xml = parse_xml(path)
1047 element = xml.find('string[@name="' + name + '"]')
1048 if element is not None:
1049 content = element_content(element)
1050 return retrieve_string(app_dir, content, xmlfiles)
1055 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1056 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1059 def manifest_paths(app_dir, flavours):
1060 '''Return list of existing files that will be used to find the highest vercode'''
1062 possible_manifests = \
1063 [os.path.join(app_dir, 'AndroidManifest.xml'),
1064 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1065 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1066 os.path.join(app_dir, 'build.gradle')]
1068 for flavour in flavours:
1069 if flavour == 'yes':
1071 possible_manifests.append(
1072 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1074 return [path for path in possible_manifests if os.path.isfile(path)]
1077 def fetch_real_name(app_dir, flavours):
1078 '''Retrieve the package name. Returns the name, or None if not found.'''
1079 for path in manifest_paths(app_dir, flavours):
1080 if not has_extension(path, 'xml') or not os.path.isfile(path):
1082 logging.debug("fetch_real_name: Checking manifest at " + path)
1083 xml = parse_xml(path)
1084 app = xml.find('application')
1087 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1089 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1090 result = retrieve_string_singleline(app_dir, label)
1092 result = result.strip()
1097 def get_library_references(root_dir):
1099 proppath = os.path.join(root_dir, 'project.properties')
1100 if not os.path.isfile(proppath):
1102 with open(proppath, 'r', encoding='iso-8859-1') as f:
1104 if not line.startswith('android.library.reference.'):
1106 path = line.split('=')[1].strip()
1107 relpath = os.path.join(root_dir, path)
1108 if not os.path.isdir(relpath):
1110 logging.debug("Found subproject at %s" % path)
1111 libraries.append(path)
1115 def ant_subprojects(root_dir):
1116 subprojects = get_library_references(root_dir)
1117 for subpath in subprojects:
1118 subrelpath = os.path.join(root_dir, subpath)
1119 for p in get_library_references(subrelpath):
1120 relp = os.path.normpath(os.path.join(subpath, p))
1121 if relp not in subprojects:
1122 subprojects.insert(0, relp)
1126 def remove_debuggable_flags(root_dir):
1127 # Remove forced debuggable flags
1128 logging.debug("Removing debuggable flags from %s" % root_dir)
1129 for root, dirs, files in os.walk(root_dir):
1130 if 'AndroidManifest.xml' in files:
1131 regsub_file(r'android:debuggable="[^"]*"',
1133 os.path.join(root, 'AndroidManifest.xml'))
1136 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1137 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1138 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1141 def app_matches_packagename(app, package):
1144 appid = app.UpdateCheckName or app.id
1145 if appid is None or appid == "Ignore":
1147 return appid == package
1150 def parse_androidmanifests(paths, app):
1152 Extract some information from the AndroidManifest.xml at the given path.
1153 Returns (version, vercode, package), any or all of which might be None.
1154 All values returned are strings.
1157 ignoreversions = app.UpdateCheckIgnore
1158 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1161 return (None, None, None)
1169 if not os.path.isfile(path):
1172 logging.debug("Parsing manifest at {0}".format(path))
1177 if has_extension(path, 'gradle'):
1178 with open(path, 'r') as f:
1180 if gradle_comment.match(line):
1182 # Grab first occurence of each to avoid running into
1183 # alternative flavours and builds.
1185 matches = psearch_g(line)
1187 s = matches.group(2)
1188 if app_matches_packagename(app, s):
1191 matches = vnsearch_g(line)
1193 version = matches.group(2)
1195 matches = vcsearch_g(line)
1197 vercode = matches.group(1)
1200 xml = parse_xml(path)
1201 if "package" in xml.attrib:
1202 s = xml.attrib["package"]
1203 if app_matches_packagename(app, s):
1205 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1206 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1207 base_dir = os.path.dirname(path)
1208 version = retrieve_string_singleline(base_dir, version)
1209 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1210 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1211 if string_is_integer(a):
1214 logging.warning("Problem with xml at {0}".format(path))
1216 # Remember package name, may be defined separately from version+vercode
1218 package = max_package
1220 logging.debug("..got package={0}, version={1}, vercode={2}"
1221 .format(package, version, vercode))
1223 # Always grab the package name and version name in case they are not
1224 # together with the highest version code
1225 if max_package is None and package is not None:
1226 max_package = package
1227 if max_version is None and version is not None:
1228 max_version = version
1230 if vercode is not None \
1231 and (max_vercode is None or vercode > max_vercode):
1232 if not ignoresearch or not ignoresearch(version):
1233 if version is not None:
1234 max_version = version
1235 if vercode is not None:
1236 max_vercode = vercode
1237 if package is not None:
1238 max_package = package
1240 max_version = "Ignore"
1242 if max_version is None:
1243 max_version = "Unknown"
1245 if max_package and not is_valid_package_name(max_package):
1246 raise FDroidException("Invalid package name {0}".format(max_package))
1248 return (max_version, max_vercode, max_package)
1251 def is_valid_package_name(name):
1252 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1255 class FDroidException(Exception):
1257 def __init__(self, value, detail=None):
1259 self.detail = detail
1261 def shortened_detail(self):
1262 if len(self.detail) < 16000:
1264 return '[...]\n' + self.detail[-16000:]
1266 def get_wikitext(self):
1267 ret = repr(self.value) + "\n"
1270 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1276 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1280 class VCSException(FDroidException):
1284 class BuildException(FDroidException):
1288 # Get the specified source library.
1289 # Returns the path to it. Normally this is the path to be used when referencing
1290 # it, which may be a subdirectory of the actual project. If you want the base
1291 # directory of the project, pass 'basepath=True'.
1292 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1293 raw=False, prepare=True, preponly=False, refresh=True,
1302 name, ref = spec.split('@')
1304 number, name = name.split(':', 1)
1306 name, subdir = name.split('/', 1)
1308 if name not in fdroidserver.metadata.srclibs:
1309 raise VCSException('srclib ' + name + ' not found.')
1311 srclib = fdroidserver.metadata.srclibs[name]
1313 sdir = os.path.join(srclib_dir, name)
1316 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1317 vcs.srclib = (name, number, sdir)
1319 vcs.gotorevision(ref, refresh)
1326 libdir = os.path.join(sdir, subdir)
1327 elif srclib["Subdir"]:
1328 for subdir in srclib["Subdir"]:
1329 libdir_candidate = os.path.join(sdir, subdir)
1330 if os.path.exists(libdir_candidate):
1331 libdir = libdir_candidate
1337 remove_signing_keys(sdir)
1338 remove_debuggable_flags(sdir)
1342 if srclib["Prepare"]:
1343 cmd = replace_config_vars(srclib["Prepare"], build)
1345 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1346 if p.returncode != 0:
1347 raise BuildException("Error running prepare command for srclib %s"
1353 return (name, number, libdir)
1356 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1359 # Prepare the source code for a particular build
1360 # 'vcs' - the appropriate vcs object for the application
1361 # 'app' - the application details from the metadata
1362 # 'build' - the build details from the metadata
1363 # 'build_dir' - the path to the build directory, usually
1365 # 'srclib_dir' - the path to the source libraries directory, usually
1367 # 'extlib_dir' - the path to the external libraries directory, usually
1369 # Returns the (root, srclibpaths) where:
1370 # 'root' is the root directory, which may be the same as 'build_dir' or may
1371 # be a subdirectory of it.
1372 # 'srclibpaths' is information on the srclibs being used
1373 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1375 # Optionally, the actual app source can be in a subdirectory
1377 root_dir = os.path.join(build_dir, build.subdir)
1379 root_dir = build_dir
1381 # Get a working copy of the right revision
1382 logging.info("Getting source for revision " + build.commit)
1383 vcs.gotorevision(build.commit, refresh)
1385 # Initialise submodules if required
1386 if build.submodules:
1387 logging.info("Initialising submodules")
1388 vcs.initsubmodules()
1390 # Check that a subdir (if we're using one) exists. This has to happen
1391 # after the checkout, since it might not exist elsewhere
1392 if not os.path.exists(root_dir):
1393 raise BuildException('Missing subdir ' + root_dir)
1395 # Run an init command if one is required
1397 cmd = replace_config_vars(build.init, build)
1398 logging.info("Running 'init' commands in %s" % root_dir)
1400 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1401 if p.returncode != 0:
1402 raise BuildException("Error running init command for %s:%s" %
1403 (app.id, build.versionName), p.output)
1405 # Apply patches if any
1407 logging.info("Applying patches")
1408 for patch in build.patch:
1409 patch = patch.strip()
1410 logging.info("Applying " + patch)
1411 patch_path = os.path.join('metadata', app.id, patch)
1412 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1413 if p.returncode != 0:
1414 raise BuildException("Failed to apply patch %s" % patch_path)
1416 # Get required source libraries
1419 logging.info("Collecting source libraries")
1420 for lib in build.srclibs:
1421 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1422 refresh=refresh, build=build))
1424 for name, number, libpath in srclibpaths:
1425 place_srclib(root_dir, int(number) if number else None, libpath)
1427 basesrclib = vcs.getsrclib()
1428 # If one was used for the main source, add that too.
1430 srclibpaths.append(basesrclib)
1432 # Update the local.properties file
1433 localprops = [os.path.join(build_dir, 'local.properties')]
1435 parts = build.subdir.split(os.sep)
1438 cur = os.path.join(cur, d)
1439 localprops += [os.path.join(cur, 'local.properties')]
1440 for path in localprops:
1442 if os.path.isfile(path):
1443 logging.info("Updating local.properties file at %s" % path)
1444 with open(path, 'r', encoding='iso-8859-1') as f:
1448 logging.info("Creating local.properties file at %s" % path)
1449 # Fix old-fashioned 'sdk-location' by copying
1450 # from sdk.dir, if necessary
1452 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1453 re.S | re.M).group(1)
1454 props += "sdk-location=%s\n" % sdkloc
1456 props += "sdk.dir=%s\n" % config['sdk_path']
1457 props += "sdk-location=%s\n" % config['sdk_path']
1458 ndk_path = build.ndk_path()
1459 # if for any reason the path isn't valid or the directory
1460 # doesn't exist, some versions of Gradle will error with a
1461 # cryptic message (even if the NDK is not even necessary).
1462 # https://gitlab.com/fdroid/fdroidserver/issues/171
1463 if ndk_path and os.path.exists(ndk_path):
1465 props += "ndk.dir=%s\n" % ndk_path
1466 props += "ndk-location=%s\n" % ndk_path
1467 # Add java.encoding if necessary
1469 props += "java.encoding=%s\n" % build.encoding
1470 with open(path, 'w', encoding='iso-8859-1') as f:
1474 if build.build_method() == 'gradle':
1475 flavours = build.gradle
1478 n = build.target.split('-')[1]
1479 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1480 r'compileSdkVersion %s' % n,
1481 os.path.join(root_dir, 'build.gradle'))
1483 # Remove forced debuggable flags
1484 remove_debuggable_flags(root_dir)
1486 # Insert version code and number into the manifest if necessary
1487 if build.forceversion:
1488 logging.info("Changing the version name")
1489 for path in manifest_paths(root_dir, flavours):
1490 if not os.path.isfile(path):
1492 if has_extension(path, 'xml'):
1493 regsub_file(r'android:versionName="[^"]*"',
1494 r'android:versionName="%s"' % build.versionName,
1496 elif has_extension(path, 'gradle'):
1497 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1498 r"""\1versionName '%s'""" % build.versionName,
1501 if build.forcevercode:
1502 logging.info("Changing the version code")
1503 for path in manifest_paths(root_dir, flavours):
1504 if not os.path.isfile(path):
1506 if has_extension(path, 'xml'):
1507 regsub_file(r'android:versionCode="[^"]*"',
1508 r'android:versionCode="%s"' % build.versionCode,
1510 elif has_extension(path, 'gradle'):
1511 regsub_file(r'versionCode[ =]+[0-9]+',
1512 r'versionCode %s' % build.versionCode,
1515 # Delete unwanted files
1517 logging.info("Removing specified files")
1518 for part in getpaths(build_dir, build.rm):
1519 dest = os.path.join(build_dir, part)
1520 logging.info("Removing {0}".format(part))
1521 if os.path.lexists(dest):
1522 if os.path.islink(dest):
1523 FDroidPopen(['unlink', dest], output=False)
1525 FDroidPopen(['rm', '-rf', dest], output=False)
1527 logging.info("...but it didn't exist")
1529 remove_signing_keys(build_dir)
1531 # Add required external libraries
1533 logging.info("Collecting prebuilt libraries")
1534 libsdir = os.path.join(root_dir, 'libs')
1535 if not os.path.exists(libsdir):
1537 for lib in build.extlibs:
1539 logging.info("...installing extlib {0}".format(lib))
1540 libf = os.path.basename(lib)
1541 libsrc = os.path.join(extlib_dir, lib)
1542 if not os.path.exists(libsrc):
1543 raise BuildException("Missing extlib file {0}".format(libsrc))
1544 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1546 # Run a pre-build command if one is required
1548 logging.info("Running 'prebuild' commands in %s" % root_dir)
1550 cmd = replace_config_vars(build.prebuild, build)
1552 # Substitute source library paths into prebuild commands
1553 for name, number, libpath in srclibpaths:
1554 libpath = os.path.relpath(libpath, root_dir)
1555 cmd = cmd.replace('$$' + name + '$$', libpath)
1557 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1558 if p.returncode != 0:
1559 raise BuildException("Error running prebuild command for %s:%s" %
1560 (app.id, build.versionName), p.output)
1562 # Generate (or update) the ant build file, build.xml...
1563 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1564 parms = ['android', 'update', 'lib-project']
1565 lparms = ['android', 'update', 'project']
1568 parms += ['-t', build.target]
1569 lparms += ['-t', build.target]
1570 if build.androidupdate:
1571 update_dirs = build.androidupdate
1573 update_dirs = ant_subprojects(root_dir) + ['.']
1575 for d in update_dirs:
1576 subdir = os.path.join(root_dir, d)
1578 logging.debug("Updating main project")
1579 cmd = parms + ['-p', d]
1581 logging.debug("Updating subproject %s" % d)
1582 cmd = lparms + ['-p', d]
1583 p = SdkToolsPopen(cmd, cwd=root_dir)
1584 # Check to see whether an error was returned without a proper exit
1585 # code (this is the case for the 'no target set or target invalid'
1587 if p.returncode != 0 or p.output.startswith("Error: "):
1588 raise BuildException("Failed to update project at %s" % d, p.output)
1589 # Clean update dirs via ant
1591 logging.info("Cleaning subproject %s" % d)
1592 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1594 return (root_dir, srclibpaths)
1597 # Extend via globbing the paths from a field and return them as a map from
1598 # original path to resulting paths
1599 def getpaths_map(build_dir, globpaths):
1603 full_path = os.path.join(build_dir, p)
1604 full_path = os.path.normpath(full_path)
1605 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1607 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1611 # Extend via globbing the paths from a field and return them as a set
1612 def getpaths(build_dir, globpaths):
1613 paths_map = getpaths_map(build_dir, globpaths)
1615 for k, v in paths_map.items():
1622 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1628 self.path = os.path.join('stats', 'known_apks.txt')
1630 if os.path.isfile(self.path):
1631 with open(self.path, 'r', encoding='utf8') as f:
1633 t = line.rstrip().split(' ')
1635 self.apks[t[0]] = (t[1], None)
1637 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1638 self.changed = False
1640 def writeifchanged(self):
1641 if not self.changed:
1644 if not os.path.exists('stats'):
1648 for apk, app in self.apks.items():
1650 line = apk + ' ' + appid
1652 line += ' ' + added.strftime('%Y-%m-%d')
1655 with open(self.path, 'w', encoding='utf8') as f:
1656 for line in sorted(lst, key=natural_key):
1657 f.write(line + '\n')
1659 def recordapk(self, apk, app, default_date=None):
1661 Record an apk (if it's new, otherwise does nothing)
1662 Returns the date it was added as a datetime instance
1664 if apk not in self.apks:
1665 if default_date is None:
1666 default_date = datetime.utcnow()
1667 self.apks[apk] = (app, default_date)
1669 _, added = self.apks[apk]
1672 # Look up information - given the 'apkname', returns (app id, date added/None).
1673 # Or returns None for an unknown apk.
1674 def getapp(self, apkname):
1675 if apkname in self.apks:
1676 return self.apks[apkname]
1679 # Get the most recent 'num' apps added to the repo, as a list of package ids
1680 # with the most recent first.
1681 def getlatest(self, num):
1683 for apk, app in self.apks.items():
1687 if apps[appid] > added:
1691 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1692 lst = [app for app, _ in sortedapps]
1697 def get_file_extension(filename):
1698 """get the normalized file extension, can be blank string but never None"""
1700 return os.path.splitext(filename)[1].lower()[1:]
1703 def isApkAndDebuggable(apkfile, config):
1704 """Returns True if the given file is an APK and is debuggable
1706 :param apkfile: full path to the apk to check"""
1708 if get_file_extension(apkfile) != 'apk':
1711 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1713 if p.returncode != 0:
1714 logging.critical("Failed to get apk manifest information")
1716 for line in p.output.splitlines():
1717 if 'android:debuggable' in line and not line.endswith('0x0'):
1724 self.returncode = None
1728 def SdkToolsPopen(commands, cwd=None, output=True):
1730 if cmd not in config:
1731 config[cmd] = find_sdk_tools_cmd(commands[0])
1732 abscmd = config[cmd]
1734 logging.critical("Could not find '%s' on your system" % cmd)
1737 test_aapt_version(config['aapt'])
1738 return FDroidPopen([abscmd] + commands[1:],
1739 cwd=cwd, output=output)
1742 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1744 Run a command and capture the possibly huge output as bytes.
1746 :param commands: command and argument list like in subprocess.Popen
1747 :param cwd: optionally specifies a working directory
1748 :returns: A PopenResult.
1753 set_FDroidPopen_env()
1756 cwd = os.path.normpath(cwd)
1757 logging.debug("Directory: %s" % cwd)
1758 logging.debug("> %s" % ' '.join(commands))
1760 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1761 result = PopenResult()
1764 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1765 stdout=subprocess.PIPE, stderr=stderr_param)
1766 except OSError as e:
1767 raise BuildException("OSError while trying to execute " +
1768 ' '.join(commands) + ': ' + str(e))
1770 if not stderr_to_stdout and options.verbose:
1771 stderr_queue = Queue()
1772 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1774 while not stderr_reader.eof():
1775 while not stderr_queue.empty():
1776 line = stderr_queue.get()
1777 sys.stderr.buffer.write(line)
1782 stdout_queue = Queue()
1783 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1786 # Check the queue for output (until there is no more to get)
1787 while not stdout_reader.eof():
1788 while not stdout_queue.empty():
1789 line = stdout_queue.get()
1790 if output and options.verbose:
1791 # Output directly to console
1792 sys.stderr.buffer.write(line)
1798 result.returncode = p.wait()
1799 result.output = buf.getvalue()
1804 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1806 Run a command and capture the possibly huge output as a str.
1808 :param commands: command and argument list like in subprocess.Popen
1809 :param cwd: optionally specifies a working directory
1810 :returns: A PopenResult.
1812 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1813 result.output = result.output.decode('utf-8', 'ignore')
1817 gradle_comment = re.compile(r'[ ]*//')
1818 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1819 gradle_line_matches = [
1820 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1821 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1822 re.compile(r'.*\.readLine\(.*'),
1826 def remove_signing_keys(build_dir):
1827 for root, dirs, files in os.walk(build_dir):
1828 if 'build.gradle' in files:
1829 path = os.path.join(root, 'build.gradle')
1831 with open(path, "r", encoding='utf8') as o:
1832 lines = o.readlines()
1838 with open(path, "w", encoding='utf8') as o:
1839 while i < len(lines):
1842 while line.endswith('\\\n'):
1843 line = line.rstrip('\\\n') + lines[i]
1846 if gradle_comment.match(line):
1851 opened += line.count('{')
1852 opened -= line.count('}')
1855 if gradle_signing_configs.match(line):
1860 if any(s.match(line) for s in gradle_line_matches):
1868 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1871 'project.properties',
1873 'default.properties',
1874 'ant.properties', ]:
1875 if propfile in files:
1876 path = os.path.join(root, propfile)
1878 with open(path, "r", encoding='iso-8859-1') as o:
1879 lines = o.readlines()
1883 with open(path, "w", encoding='iso-8859-1') as o:
1885 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1892 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1895 def set_FDroidPopen_env(build=None):
1897 set up the environment variables for the build environment
1899 There is only a weak standard, the variables used by gradle, so also set
1900 up the most commonly used environment variables for SDK and NDK. Also, if
1901 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1903 global env, orig_path
1907 orig_path = env['PATH']
1908 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1909 env[n] = config['sdk_path']
1910 for k, v in config['java_paths'].items():
1911 env['JAVA%s_HOME' % k] = v
1913 missinglocale = True
1914 for k, v in env.items():
1915 if k == 'LANG' and v != 'C':
1916 missinglocale = False
1918 missinglocale = False
1920 env['LANG'] = 'en_US.UTF-8'
1922 if build is not None:
1923 path = build.ndk_path()
1924 paths = orig_path.split(os.pathsep)
1925 if path not in paths:
1926 paths = [path] + paths
1927 env['PATH'] = os.pathsep.join(paths)
1928 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1929 env[n] = build.ndk_path()
1932 def replace_build_vars(cmd, build):
1933 cmd = cmd.replace('$$COMMIT$$', build.commit)
1934 cmd = cmd.replace('$$VERSION$$', build.versionName)
1935 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1939 def replace_config_vars(cmd, build):
1940 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1941 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1942 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1943 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1944 if build is not None:
1945 cmd = replace_build_vars(cmd, build)
1949 def place_srclib(root_dir, number, libpath):
1952 relpath = os.path.relpath(libpath, root_dir)
1953 proppath = os.path.join(root_dir, 'project.properties')
1956 if os.path.isfile(proppath):
1957 with open(proppath, "r", encoding='iso-8859-1') as o:
1958 lines = o.readlines()
1960 with open(proppath, "w", encoding='iso-8859-1') as o:
1963 if line.startswith('android.library.reference.%d=' % number):
1964 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1969 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1972 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1975 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1976 """Verify that two apks are the same
1978 One of the inputs is signed, the other is unsigned. The signature metadata
1979 is transferred from the signed to the unsigned apk, and then jarsigner is
1980 used to verify that the signature from the signed apk is also varlid for
1981 the unsigned one. If the APK given as unsigned actually does have a
1982 signature, it will be stripped out and ignored.
1984 There are two SHA1 git commit IDs that fdroidserver includes in the builds
1985 it makes: fdroidserverid and buildserverid. Originally, these were inserted
1986 into AndroidManifest.xml, but that makes the build not reproducible. So
1987 instead they are included as separate files in the APK's META-INF/ folder.
1988 If those files exist in the signed APK, they will be part of the signature
1989 and need to also be included in the unsigned APK for it to validate.
1991 :param signed_apk: Path to a signed apk file
1992 :param unsigned_apk: Path to an unsigned apk file expected to match it
1993 :param tmp_dir: Path to directory for temporary files
1994 :returns: None if the verification is successful, otherwise a string
1995 describing what went wrong.
1998 signed = ZipFile(signed_apk, 'r')
1999 meta_inf_files = ['META-INF/MANIFEST.MF']
2000 for f in signed.namelist():
2001 if apk_sigfile.match(f) \
2002 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2003 meta_inf_files.append(f)
2004 if len(meta_inf_files) < 3:
2005 return "Signature files missing from {0}".format(signed_apk)
2007 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2008 unsigned = ZipFile(unsigned_apk, 'r')
2009 # only read the signature from the signed APK, everything else from unsigned
2010 with ZipFile(tmp_apk, 'w') as tmp:
2011 for filename in meta_inf_files:
2012 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2013 for info in unsigned.infolist():
2014 if info.filename in meta_inf_files:
2015 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2017 if info.filename in tmp.namelist():
2018 return "duplicate filename found: " + info.filename
2019 tmp.writestr(info, unsigned.read(info.filename))
2023 verified = verify_apk_signature(tmp_apk)
2026 logging.info("...NOT verified - {0}".format(tmp_apk))
2027 return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2029 logging.info("...successfully verified")
2033 def verify_apk_signature(apk, jar=False):
2034 """verify the signature on an APK
2036 Try to use apksigner whenever possible since jarsigner is very
2037 shitty: unsigned APKs pass as "verified"! So this has to turn on
2038 -strict then check for result 4.
2040 You can set :param: jar to True if you want to use this method
2041 to verify jar signatures.
2043 if set_command_in_config('apksigner'):
2044 args = [config['apksigner'], 'verify']
2046 args += ['--min-sdk-version=1']
2047 return subprocess.call(args + [apk]) == 0
2049 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2050 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2053 apk_badchars = re.compile('''[/ :;'"]''')
2056 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2059 Returns None if the apk content is the same (apart from the signing key),
2060 otherwise a string describing what's different, or what went wrong when
2061 trying to do the comparison.
2067 absapk1 = os.path.abspath(apk1)
2068 absapk2 = os.path.abspath(apk2)
2070 if set_command_in_config('diffoscope'):
2071 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2072 htmlfile = logfilename + '.diffoscope.html'
2073 textfile = logfilename + '.diffoscope.txt'
2074 if subprocess.call([config['diffoscope'],
2075 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2076 '--html', htmlfile, '--text', textfile,
2077 absapk1, absapk2]) != 0:
2078 return("Failed to unpack " + apk1)
2080 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2081 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2082 for d in [apk1dir, apk2dir]:
2083 if os.path.exists(d):
2086 os.mkdir(os.path.join(d, 'jar-xf'))
2088 if subprocess.call(['jar', 'xf',
2089 os.path.abspath(apk1)],
2090 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2091 return("Failed to unpack " + apk1)
2092 if subprocess.call(['jar', 'xf',
2093 os.path.abspath(apk2)],
2094 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2095 return("Failed to unpack " + apk2)
2097 if set_command_in_config('apktool'):
2098 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2100 return("Failed to unpack " + apk1)
2101 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2103 return("Failed to unpack " + apk2)
2105 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2106 lines = p.output.splitlines()
2107 if len(lines) != 1 or 'META-INF' not in lines[0]:
2108 meld = find_command('meld')
2109 if meld is not None:
2110 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2111 return("Unexpected diff output - " + p.output)
2113 # since everything verifies, delete the comparison to keep cruft down
2114 shutil.rmtree(apk1dir)
2115 shutil.rmtree(apk2dir)
2117 # If we get here, it seems like they're the same!
2121 def set_command_in_config(command):
2122 '''Try to find specified command in the path, if it hasn't been
2123 manually set in config.py. If found, it is added to the config
2124 dict. The return value says whether the command is available.
2127 if command in config:
2130 tmp = find_command(command)
2132 config[command] = tmp
2137 def find_command(command):
2138 '''find the full path of a command, or None if it can't be found in the PATH'''
2141 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2143 fpath, fname = os.path.split(command)
2148 for path in os.environ["PATH"].split(os.pathsep):
2149 path = path.strip('"')
2150 exe_file = os.path.join(path, command)
2151 if is_exe(exe_file):
2158 '''generate a random password for when generating keys'''
2159 h = hashlib.sha256()
2160 h.update(os.urandom(16)) # salt
2161 h.update(socket.getfqdn().encode('utf-8'))
2162 passwd = base64.b64encode(h.digest()).strip()
2163 return passwd.decode('utf-8')
2166 def genkeystore(localconfig):
2168 Generate a new key with password provided in :param localconfig and add it to new keystore
2169 :return: hexed public key, public key fingerprint
2171 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2172 keystoredir = os.path.dirname(localconfig['keystore'])
2173 if keystoredir is None or keystoredir == '':
2174 keystoredir = os.path.join(os.getcwd(), keystoredir)
2175 if not os.path.exists(keystoredir):
2176 os.makedirs(keystoredir, mode=0o700)
2178 write_password_file("keystorepass", localconfig['keystorepass'])
2179 write_password_file("keypass", localconfig['keypass'])
2180 p = FDroidPopen([config['keytool'], '-genkey',
2181 '-keystore', localconfig['keystore'],
2182 '-alias', localconfig['repo_keyalias'],
2183 '-keyalg', 'RSA', '-keysize', '4096',
2184 '-sigalg', 'SHA256withRSA',
2185 '-validity', '10000',
2186 '-storepass:file', config['keystorepassfile'],
2187 '-keypass:file', config['keypassfile'],
2188 '-dname', localconfig['keydname']])
2189 # TODO keypass should be sent via stdin
2190 if p.returncode != 0:
2191 raise BuildException("Failed to generate key", p.output)
2192 os.chmod(localconfig['keystore'], 0o0600)
2193 if not options.quiet:
2194 # now show the lovely key that was just generated
2195 p = FDroidPopen([config['keytool'], '-list', '-v',
2196 '-keystore', localconfig['keystore'],
2197 '-alias', localconfig['repo_keyalias'],
2198 '-storepass:file', config['keystorepassfile']])
2199 logging.info(p.output.strip() + '\n\n')
2200 # get the public key
2201 p = FDroidPopenBytes([config['keytool'], '-exportcert',
2202 '-keystore', localconfig['keystore'],
2203 '-alias', localconfig['repo_keyalias'],
2204 '-storepass:file', config['keystorepassfile']]
2205 + config['smartcardoptions'],
2206 output=False, stderr_to_stdout=False)
2207 if p.returncode != 0 or len(p.output) < 20:
2208 raise BuildException("Failed to get public key", p.output)
2210 fingerprint = get_cert_fingerprint(pubkey)
2211 return hexlify(pubkey), fingerprint
2214 def get_cert_fingerprint(pubkey):
2216 Generate a certificate fingerprint the same way keytool does it
2217 (but with slightly different formatting)
2219 digest = hashlib.sha256(pubkey).digest()
2220 ret = [' '.join("%02X" % b for b in bytearray(digest))]
2221 return " ".join(ret)
2224 def write_to_config(thisconfig, key, value=None, config_file=None):
2225 '''write a key/value to the local config.py
2227 NOTE: only supports writing string variables.
2229 :param thisconfig: config dictionary
2230 :param key: variable name in config.py to be overwritten/added
2231 :param value: optional value to be written, instead of fetched
2232 from 'thisconfig' dictionary.
2235 origkey = key + '_orig'
2236 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2237 cfg = config_file if config_file else 'config.py'
2240 with open(cfg, 'r', encoding="utf-8") as f:
2241 lines = f.readlines()
2243 # make sure the file ends with a carraige return
2245 if not lines[-1].endswith('\n'):
2248 # regex for finding and replacing python string variable
2249 # definitions/initializations
2250 pattern = re.compile('^[\s#]*' + key + '\s*=\s*"[^"]*"')
2251 repl = key + ' = "' + value + '"'
2252 pattern2 = re.compile('^[\s#]*' + key + "\s*=\s*'[^']*'")
2253 repl2 = key + " = '" + value + "'"
2255 # If we replaced this line once, we make sure won't be a
2256 # second instance of this line for this key in the document.
2259 with open(cfg, 'w', encoding="utf-8") as f:
2261 if pattern.match(line) or pattern2.match(line):
2263 line = pattern.sub(repl, line)
2264 line = pattern2.sub(repl2, line)
2275 def parse_xml(path):
2276 return XMLElementTree.parse(path).getroot()
2279 def string_is_integer(string):
2287 def get_per_app_repos():
2288 '''per-app repos are dirs named with the packageName of a single app'''
2290 # Android packageNames are Java packages, they may contain uppercase or
2291 # lowercase letters ('A' through 'Z'), numbers, and underscores
2292 # ('_'). However, individual package name parts may only start with
2293 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2294 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2297 for root, dirs, files in os.walk(os.getcwd()):
2299 print('checking', root, 'for', d)
2300 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2301 # standard parts of an fdroid repo, so never packageNames
2304 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2310 def is_repo_file(filename):
2311 '''Whether the file in a repo is a build product to be delivered to users'''
2312 return os.path.isfile(filename) \
2313 and not filename.endswith('.asc') \
2314 and not filename.endswith('.sig') \
2315 and os.path.basename(filename) not in [
2317 'index_unsigned.jar',