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 datetime import datetime
40 from distutils.version import LooseVersion
41 from queue import Queue
42 from zipfile import ZipFile
44 import fdroidserver.metadata
45 from .asynchronousfilereader import AsynchronousFileReader
48 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
57 'sdk_path': "$ANDROID_HOME",
62 'r12b': "$ANDROID_NDK",
66 'build_tools': "25.0.2",
67 'force_build_tools': False,
72 'accepted_formats': ['txt', 'yml'],
73 'sync_from_local_copy_dir': False,
74 'per_app_repos': False,
75 'make_current_version_link': True,
76 'current_version_name_source': 'Name',
77 'update_stats': False,
81 'stats_to_carbon': False,
83 'build_server_always': False,
84 'keystore': 'keystore.jks',
85 'smartcardoptions': [],
91 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
92 'repo_name': "My First FDroid Repo Demo",
93 'repo_icon': "fdroid-icon.png",
94 'repo_description': '''
95 This is a repository of apps to be used with FDroid. Applications in this
96 repository are either official binaries built by the original application
97 developers, or are binaries built from source by the admin of f-droid.org
98 using the tools on https://gitlab.com/u/fdroid.
104 def setup_global_opts(parser):
105 parser.add_argument("-v", "--verbose", action="store_true", default=False,
106 help="Spew out even more information than normal")
107 parser.add_argument("-q", "--quiet", action="store_true", default=False,
108 help="Restrict output to warnings and errors")
111 def fill_config_defaults(thisconfig):
112 for k, v in default_config.items():
113 if k not in thisconfig:
116 # Expand paths (~users and $vars)
117 def expand_path(path):
121 path = os.path.expanduser(path)
122 path = os.path.expandvars(path)
127 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
132 thisconfig[k + '_orig'] = v
134 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
135 if thisconfig['java_paths'] is None:
136 thisconfig['java_paths'] = dict()
138 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
139 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
140 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
141 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
142 if os.getenv('JAVA_HOME') is not None:
143 pathlist.append(os.getenv('JAVA_HOME'))
144 if os.getenv('PROGRAMFILES') is not None:
145 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
146 for d in sorted(pathlist):
147 if os.path.islink(d):
149 j = os.path.basename(d)
150 # the last one found will be the canonical one, so order appropriately
152 r'^1\.([6-9])\.0\.jdk$', # OSX
153 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
154 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
155 r'^jdk([6-9])-openjdk$', # Arch
156 r'^java-([6-9])-openjdk$', # Arch
157 r'^java-([6-9])-jdk$', # Arch (oracle)
158 r'^java-1\.([6-9])\.0-.*$', # RedHat
159 r'^java-([6-9])-oracle$', # Debian WebUpd8
160 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
161 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
163 m = re.match(regex, j)
166 for p in [d, os.path.join(d, 'Contents', 'Home')]:
167 if os.path.exists(os.path.join(p, 'bin', 'javac')):
168 thisconfig['java_paths'][m.group(1)] = p
170 for java_version in ('7', '8', '9'):
171 if java_version not in thisconfig['java_paths']:
173 java_home = thisconfig['java_paths'][java_version]
174 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
175 if os.path.exists(jarsigner):
176 thisconfig['jarsigner'] = jarsigner
177 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
178 break # Java7 is preferred, so quit if found
180 for k in ['ndk_paths', 'java_paths']:
186 thisconfig[k][k2] = exp
187 thisconfig[k][k2 + '_orig'] = v
190 def regsub_file(pattern, repl, path):
191 with open(path, 'rb') as f:
193 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
194 with open(path, 'wb') as f:
198 def read_config(opts, config_file='config.py'):
199 """Read the repository config
201 The config is read from config_file, which is in the current
202 directory when any of the repo management commands are used. If
203 there is a local metadata file in the git repo, then config.py is
204 not required, just use defaults.
207 global config, options
209 if config is not None:
216 if os.path.isfile(config_file):
217 logging.debug("Reading %s" % config_file)
218 with io.open(config_file, "rb") as f:
219 code = compile(f.read(), config_file, 'exec')
220 exec(code, None, config)
221 elif len(get_local_metadata_files()) == 0:
222 logging.critical("Missing config file - is this a repo directory?")
225 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
227 if not type(config[k]) in (str, list, tuple):
228 logging.warn('"' + k + '" will be in random order!'
229 + ' Use () or [] brackets if order is important!')
231 # smartcardoptions must be a list since its command line args for Popen
232 if 'smartcardoptions' in config:
233 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
234 elif 'keystore' in config and config['keystore'] == 'NONE':
235 # keystore='NONE' means use smartcard, these are required defaults
236 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
237 'SunPKCS11-OpenSC', '-providerClass',
238 'sun.security.pkcs11.SunPKCS11',
239 '-providerArg', 'opensc-fdroid.cfg']
241 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
242 st = os.stat(config_file)
243 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
244 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
246 fill_config_defaults(config)
248 for k in ["keystorepass", "keypass"]:
250 write_password_file(k)
252 for k in ["repo_description", "archive_description"]:
254 config[k] = clean_description(config[k])
256 if 'serverwebroot' in config:
257 if isinstance(config['serverwebroot'], str):
258 roots = [config['serverwebroot']]
259 elif all(isinstance(item, str) for item in config['serverwebroot']):
260 roots = config['serverwebroot']
262 raise TypeError('only accepts strings, lists, and tuples')
264 for rootstr in roots:
265 # since this is used with rsync, where trailing slashes have
266 # meaning, ensure there is always a trailing slash
267 if rootstr[-1] != '/':
269 rootlist.append(rootstr.replace('//', '/'))
270 config['serverwebroot'] = rootlist
272 if 'servergitmirrors' in config:
273 if isinstance(config['servergitmirrors'], str):
274 roots = [config['servergitmirrors']]
275 elif all(isinstance(item, str) for item in config['servergitmirrors']):
276 roots = config['servergitmirrors']
278 raise TypeError('only accepts strings, lists, and tuples')
279 config['servergitmirrors'] = roots
284 def find_sdk_tools_cmd(cmd):
285 '''find a working path to a tool from the Android SDK'''
288 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
289 # try to find a working path to this command, in all the recent possible paths
290 if 'build_tools' in config:
291 build_tools = os.path.join(config['sdk_path'], 'build-tools')
292 # if 'build_tools' was manually set and exists, check only that one
293 configed_build_tools = os.path.join(build_tools, config['build_tools'])
294 if os.path.exists(configed_build_tools):
295 tooldirs.append(configed_build_tools)
297 # no configed version, so hunt known paths for it
298 for f in sorted(os.listdir(build_tools), reverse=True):
299 if os.path.isdir(os.path.join(build_tools, f)):
300 tooldirs.append(os.path.join(build_tools, f))
301 tooldirs.append(build_tools)
302 sdk_tools = os.path.join(config['sdk_path'], 'tools')
303 if os.path.exists(sdk_tools):
304 tooldirs.append(sdk_tools)
305 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
306 if os.path.exists(sdk_platform_tools):
307 tooldirs.append(sdk_platform_tools)
308 tooldirs.append('/usr/bin')
310 path = os.path.join(d, cmd)
311 if os.path.isfile(path):
313 test_aapt_version(path)
315 # did not find the command, exit with error message
316 ensure_build_tools_exists(config)
319 def test_aapt_version(aapt):
320 '''Check whether the version of aapt is new enough'''
321 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
322 if output is None or output == '':
323 logging.error(aapt + ' failed to execute!')
325 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
330 # the Debian package has the version string like "v0.2-23.0.2"
331 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
332 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
334 logging.warning('Unknown version of aapt, might cause problems: ' + output)
337 def test_sdk_exists(thisconfig):
338 if 'sdk_path' not in thisconfig:
339 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
340 test_aapt_version(thisconfig['aapt'])
343 logging.error("'sdk_path' not set in config.py!")
345 if thisconfig['sdk_path'] == default_config['sdk_path']:
346 logging.error('No Android SDK found!')
347 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
348 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
350 if not os.path.exists(thisconfig['sdk_path']):
351 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
353 if not os.path.isdir(thisconfig['sdk_path']):
354 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
356 for d in ['build-tools', 'platform-tools', 'tools']:
357 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
358 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
359 thisconfig['sdk_path'], d))
364 def ensure_build_tools_exists(thisconfig):
365 if not test_sdk_exists(thisconfig):
367 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
368 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
369 if not os.path.isdir(versioned_build_tools):
370 logging.critical('Android Build Tools path "'
371 + versioned_build_tools + '" does not exist!')
375 def write_password_file(pwtype, password=None):
377 writes out passwords to a protected file instead of passing passwords as
378 command line argments
380 filename = '.fdroid.' + pwtype + '.txt'
381 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
383 os.write(fd, config[pwtype].encode('utf-8'))
385 os.write(fd, password.encode('utf-8'))
387 config[pwtype + 'file'] = filename
392 sign a JAR file with Java's jarsigner.
394 This does use old hashing algorithms, i.e. SHA1, but that's not
395 broken yet for file verification. This could be set to SHA256,
396 but then Android < 4.3 would not be able to verify it.
397 https://code.google.com/p/android/issues/detail?id=38321
399 args = [config['jarsigner'], '-keystore', config['keystore'],
400 '-storepass:file', config['keystorepassfile'],
401 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
402 jar, config['repo_keyalias']]
403 if config['keystore'] == 'NONE':
404 args += config['smartcardoptions']
405 else: # smardcards never use -keypass
406 args += ['-keypass:file', config['keypassfile']]
407 p = FDroidPopen(args)
408 if p.returncode != 0:
409 logging.critical("Failed to sign %s!" % jar)
413 def get_local_metadata_files():
414 '''get any metadata files local to an app's source repo
416 This tries to ignore anything that does not count as app metdata,
417 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
420 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
423 def read_pkg_args(args, allow_vercodes=False):
425 Given the arguments in the form of multiple appid:[vc] strings, this returns
426 a dictionary with the set of vercodes specified for each package.
434 if allow_vercodes and ':' in p:
435 package, vercode = p.split(':')
437 package, vercode = p, None
438 if package not in vercodes:
439 vercodes[package] = [vercode] if vercode else []
441 elif vercode and vercode not in vercodes[package]:
442 vercodes[package] += [vercode] if vercode else []
447 def read_app_args(args, allapps, allow_vercodes=False):
449 On top of what read_pkg_args does, this returns the whole app metadata, but
450 limiting the builds list to the builds matching the vercodes specified.
453 vercodes = read_pkg_args(args, allow_vercodes)
459 for appid, app in allapps.items():
460 if appid in vercodes:
463 if len(apps) != len(vercodes):
466 logging.critical("No such package: %s" % p)
467 raise FDroidException("Found invalid app ids in arguments")
469 raise FDroidException("No packages specified")
472 for appid, app in apps.items():
476 app.builds = [b for b in app.builds if b.versionCode in vc]
477 if len(app.builds) != len(vercodes[appid]):
479 allvcs = [b.versionCode for b in app.builds]
480 for v in vercodes[appid]:
482 logging.critical("No such vercode %s for app %s" % (v, appid))
485 raise FDroidException("Found invalid vercodes for some apps")
490 def get_extension(filename):
491 base, ext = os.path.splitext(filename)
494 return base, ext.lower()[1:]
497 def has_extension(filename, ext):
498 _, f_ext = get_extension(filename)
502 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
505 def clean_description(description):
506 'Remove unneeded newlines and spaces from a block of description text'
508 # this is split up by paragraph to make removing the newlines easier
509 for paragraph in re.split(r'\n\n', description):
510 paragraph = re.sub('\r', '', paragraph)
511 paragraph = re.sub('\n', ' ', paragraph)
512 paragraph = re.sub(' {2,}', ' ', paragraph)
513 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
514 returnstring += paragraph + '\n\n'
515 return returnstring.rstrip('\n')
518 def publishednameinfo(filename):
519 filename = os.path.basename(filename)
520 m = publish_name_regex.match(filename)
522 result = (m.group(1), m.group(2))
523 except AttributeError:
524 raise FDroidException("Invalid name for published file: %s" % filename)
528 def get_release_filename(app, build):
530 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
532 return "%s_%s.apk" % (app.id, build.versionCode)
535 def getsrcname(app, build):
536 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
548 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
551 def get_build_dir(app):
552 '''get the dir that this app will be built in'''
554 if app.RepoType == 'srclib':
555 return os.path.join('build', 'srclib', app.Repo)
557 return os.path.join('build', app.id)
561 '''checkout code from VCS and return instance of vcs and the build dir'''
562 build_dir = get_build_dir(app)
564 # Set up vcs interface and make sure we have the latest code...
565 logging.debug("Getting {0} vcs interface for {1}"
566 .format(app.RepoType, app.Repo))
567 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
571 vcs = getvcs(app.RepoType, remote, build_dir)
573 return vcs, build_dir
576 def getvcs(vcstype, remote, local):
578 return vcs_git(remote, local)
579 if vcstype == 'git-svn':
580 return vcs_gitsvn(remote, local)
582 return vcs_hg(remote, local)
584 return vcs_bzr(remote, local)
585 if vcstype == 'srclib':
586 if local != os.path.join('build', 'srclib', remote):
587 raise VCSException("Error: srclib paths are hard-coded!")
588 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
590 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
591 raise VCSException("Invalid vcs type " + vcstype)
594 def getsrclibvcs(name):
595 if name not in fdroidserver.metadata.srclibs:
596 raise VCSException("Missing srclib " + name)
597 return fdroidserver.metadata.srclibs[name]['Repo Type']
602 def __init__(self, remote, local):
604 # svn, git-svn and bzr may require auth
606 if self.repotype() in ('git-svn', 'bzr'):
608 if self.repotype == 'git-svn':
609 raise VCSException("Authentication is not supported for git-svn")
610 self.username, remote = remote.split('@')
611 if ':' not in self.username:
612 raise VCSException("Password required with username")
613 self.username, self.password = self.username.split(':')
617 self.clone_failed = False
618 self.refreshed = False
624 # Take the local repository to a clean version of the given revision, which
625 # is specificed in the VCS's native format. Beforehand, the repository can
626 # be dirty, or even non-existent. If the repository does already exist
627 # locally, it will be updated from the origin, but only once in the
628 # lifetime of the vcs object.
629 # None is acceptable for 'rev' if you know you are cloning a clean copy of
630 # the repo - otherwise it must specify a valid revision.
631 def gotorevision(self, rev, refresh=True):
633 if self.clone_failed:
634 raise VCSException("Downloading the repository already failed once, not trying again.")
636 # The .fdroidvcs-id file for a repo tells us what VCS type
637 # and remote that directory was created from, allowing us to drop it
638 # automatically if either of those things changes.
639 fdpath = os.path.join(self.local, '..',
640 '.fdroidvcs-' + os.path.basename(self.local))
641 fdpath = os.path.normpath(fdpath)
642 cdata = self.repotype() + ' ' + self.remote
645 if os.path.exists(self.local):
646 if os.path.exists(fdpath):
647 with open(fdpath, 'r') as f:
648 fsdata = f.read().strip()
653 logging.info("Repository details for %s changed - deleting" % (
657 logging.info("Repository details for %s missing - deleting" % (
660 shutil.rmtree(self.local)
664 self.refreshed = True
667 self.gotorevisionx(rev)
668 except FDroidException as e:
671 # If necessary, write the .fdroidvcs file.
672 if writeback and not self.clone_failed:
673 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
674 with open(fdpath, 'w+') as f:
680 # Derived classes need to implement this. It's called once basic checking
681 # has been performend.
682 def gotorevisionx(self, rev):
683 raise VCSException("This VCS type doesn't define gotorevisionx")
685 # Initialise and update submodules
686 def initsubmodules(self):
687 raise VCSException('Submodules not supported for this vcs type')
689 # Get a list of all known tags
691 if not self._gettags:
692 raise VCSException('gettags not supported for this vcs type')
694 for tag in self._gettags():
695 if re.match('[-A-Za-z0-9_. /]+$', tag):
699 # Get a list of all the known tags, sorted from newest to oldest
700 def latesttags(self):
701 raise VCSException('latesttags not supported for this vcs type')
703 # Get current commit reference (hash, revision, etc)
705 raise VCSException('getref not supported for this vcs type')
707 # Returns the srclib (name, path) used in setting up the current
718 # If the local directory exists, but is somehow not a git repository, git
719 # will traverse up the directory tree until it finds one that is (i.e.
720 # fdroidserver) and then we'll proceed to destroy it! This is called as
723 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
724 result = p.output.rstrip()
725 if not result.endswith(self.local):
726 raise VCSException('Repository mismatch')
728 def gotorevisionx(self, rev):
729 if not os.path.exists(self.local):
731 p = FDroidPopen(['git', 'clone', self.remote, self.local])
732 if p.returncode != 0:
733 self.clone_failed = True
734 raise VCSException("Git clone failed", p.output)
738 # Discard any working tree changes
739 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
740 'git', 'reset', '--hard'], cwd=self.local, output=False)
741 if p.returncode != 0:
742 raise VCSException("Git reset failed", p.output)
743 # Remove untracked files now, in case they're tracked in the target
744 # revision (it happens!)
745 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
746 'git', 'clean', '-dffx'], cwd=self.local, output=False)
747 if p.returncode != 0:
748 raise VCSException("Git clean failed", p.output)
749 if not self.refreshed:
750 # Get latest commits and tags from remote
751 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
752 if p.returncode != 0:
753 raise VCSException("Git fetch failed", p.output)
754 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
755 if p.returncode != 0:
756 raise VCSException("Git fetch failed", p.output)
757 # Recreate origin/HEAD as git clone would do it, in case it disappeared
758 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
759 if p.returncode != 0:
760 lines = p.output.splitlines()
761 if 'Multiple remote HEAD branches' not in lines[0]:
762 raise VCSException("Git remote set-head failed", p.output)
763 branch = lines[1].split(' ')[-1]
764 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
765 if p2.returncode != 0:
766 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
767 self.refreshed = True
768 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
769 # a github repo. Most of the time this is the same as origin/master.
770 rev = rev or 'origin/HEAD'
771 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
772 if p.returncode != 0:
773 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
774 # Get rid of any uncontrolled files left behind
775 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
776 if p.returncode != 0:
777 raise VCSException("Git clean failed", p.output)
779 def initsubmodules(self):
781 submfile = os.path.join(self.local, '.gitmodules')
782 if not os.path.isfile(submfile):
783 raise VCSException("No git submodules available")
785 # fix submodules not accessible without an account and public key auth
786 with open(submfile, 'r') as f:
787 lines = f.readlines()
788 with open(submfile, 'w') as f:
790 if 'git@github.com' in line:
791 line = line.replace('git@github.com:', 'https://github.com/')
792 if 'git@gitlab.com' in line:
793 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
796 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
797 if p.returncode != 0:
798 raise VCSException("Git submodule sync failed", p.output)
799 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
800 if p.returncode != 0:
801 raise VCSException("Git submodule update failed", p.output)
805 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
806 return p.output.splitlines()
808 tag_format = re.compile(r'tag: ([^),]*)')
810 def latesttags(self):
812 p = FDroidPopen(['git', 'log', '--tags',
813 '--simplify-by-decoration', '--pretty=format:%d'],
814 cwd=self.local, output=False)
816 for line in p.output.splitlines():
817 for tag in self.tag_format.findall(line):
822 class vcs_gitsvn(vcs):
827 # If the local directory exists, but is somehow not a git repository, git
828 # will traverse up the directory tree until it finds one that is (i.e.
829 # fdroidserver) and then we'll proceed to destory it! This is called as
832 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
833 result = p.output.rstrip()
834 if not result.endswith(self.local):
835 raise VCSException('Repository mismatch')
837 def gotorevisionx(self, rev):
838 if not os.path.exists(self.local):
840 gitsvn_args = ['git', 'svn', 'clone']
841 if ';' in self.remote:
842 remote_split = self.remote.split(';')
843 for i in remote_split[1:]:
844 if i.startswith('trunk='):
845 gitsvn_args.extend(['-T', i[6:]])
846 elif i.startswith('tags='):
847 gitsvn_args.extend(['-t', i[5:]])
848 elif i.startswith('branches='):
849 gitsvn_args.extend(['-b', i[9:]])
850 gitsvn_args.extend([remote_split[0], self.local])
851 p = FDroidPopen(gitsvn_args, output=False)
852 if p.returncode != 0:
853 self.clone_failed = True
854 raise VCSException("Git svn clone failed", p.output)
856 gitsvn_args.extend([self.remote, self.local])
857 p = FDroidPopen(gitsvn_args, output=False)
858 if p.returncode != 0:
859 self.clone_failed = True
860 raise VCSException("Git svn clone failed", p.output)
864 # Discard any working tree changes
865 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
866 if p.returncode != 0:
867 raise VCSException("Git reset failed", p.output)
868 # Remove untracked files now, in case they're tracked in the target
869 # revision (it happens!)
870 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
871 if p.returncode != 0:
872 raise VCSException("Git clean failed", p.output)
873 if not self.refreshed:
874 # Get new commits, branches and tags from repo
875 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
876 if p.returncode != 0:
877 raise VCSException("Git svn fetch failed")
878 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
879 if p.returncode != 0:
880 raise VCSException("Git svn rebase failed", p.output)
881 self.refreshed = True
883 rev = rev or 'master'
885 nospaces_rev = rev.replace(' ', '%20')
886 # Try finding a svn tag
887 for treeish in ['origin/', '']:
888 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
889 if p.returncode == 0:
891 if p.returncode != 0:
892 # No tag found, normal svn rev translation
893 # Translate svn rev into git format
894 rev_split = rev.split('/')
897 for treeish in ['origin/', '']:
898 if len(rev_split) > 1:
899 treeish += rev_split[0]
900 svn_rev = rev_split[1]
903 # if no branch is specified, then assume trunk (i.e. 'master' branch):
907 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
909 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
910 git_rev = p.output.rstrip()
912 if p.returncode == 0 and git_rev:
915 if p.returncode != 0 or not git_rev:
916 # Try a plain git checkout as a last resort
917 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
918 if p.returncode != 0:
919 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
921 # Check out the git rev equivalent to the svn rev
922 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
923 if p.returncode != 0:
924 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
926 # Get rid of any uncontrolled files left behind
927 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
928 if p.returncode != 0:
929 raise VCSException("Git clean failed", p.output)
933 for treeish in ['origin/', '']:
934 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
940 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
941 if p.returncode != 0:
943 return p.output.strip()
951 def gotorevisionx(self, rev):
952 if not os.path.exists(self.local):
953 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
954 if p.returncode != 0:
955 self.clone_failed = True
956 raise VCSException("Hg clone failed", p.output)
958 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
959 if p.returncode != 0:
960 raise VCSException("Hg status failed", p.output)
961 for line in p.output.splitlines():
962 if not line.startswith('? '):
963 raise VCSException("Unexpected output from hg status -uS: " + line)
964 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
965 if not self.refreshed:
966 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
967 if p.returncode != 0:
968 raise VCSException("Hg pull failed", p.output)
969 self.refreshed = True
971 rev = rev or 'default'
974 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
975 if p.returncode != 0:
976 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
977 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
978 # Also delete untracked files, we have to enable purge extension for that:
979 if "'purge' is provided by the following extension" in p.output:
980 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
981 myfile.write("\n[extensions]\nhgext.purge=\n")
982 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
983 if p.returncode != 0:
984 raise VCSException("HG purge failed", p.output)
985 elif p.returncode != 0:
986 raise VCSException("HG purge failed", p.output)
989 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
990 return p.output.splitlines()[1:]
998 def gotorevisionx(self, rev):
999 if not os.path.exists(self.local):
1000 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
1001 if p.returncode != 0:
1002 self.clone_failed = True
1003 raise VCSException("Bzr branch failed", p.output)
1005 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1006 if p.returncode != 0:
1007 raise VCSException("Bzr revert failed", p.output)
1008 if not self.refreshed:
1009 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1010 if p.returncode != 0:
1011 raise VCSException("Bzr update failed", p.output)
1012 self.refreshed = True
1014 revargs = list(['-r', rev] if rev else [])
1015 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1016 if p.returncode != 0:
1017 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1020 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1021 return [tag.split(' ')[0].strip() for tag in
1022 p.output.splitlines()]
1025 def unescape_string(string):
1028 if string[0] == '"' and string[-1] == '"':
1031 return string.replace("\\'", "'")
1034 def retrieve_string(app_dir, string, xmlfiles=None):
1036 if not string.startswith('@string/'):
1037 return unescape_string(string)
1039 if xmlfiles is None:
1042 os.path.join(app_dir, 'res'),
1043 os.path.join(app_dir, 'src', 'main', 'res'),
1045 for r, d, f in os.walk(res_dir):
1046 if os.path.basename(r) == 'values':
1047 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1049 name = string[len('@string/'):]
1051 def element_content(element):
1052 if element.text is None:
1054 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1055 return s.decode('utf-8').strip()
1057 for path in xmlfiles:
1058 if not os.path.isfile(path):
1060 xml = parse_xml(path)
1061 element = xml.find('string[@name="' + name + '"]')
1062 if element is not None:
1063 content = element_content(element)
1064 return retrieve_string(app_dir, content, xmlfiles)
1069 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1070 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1073 def manifest_paths(app_dir, flavours):
1074 '''Return list of existing files that will be used to find the highest vercode'''
1076 possible_manifests = \
1077 [os.path.join(app_dir, 'AndroidManifest.xml'),
1078 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1079 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1080 os.path.join(app_dir, 'build.gradle')]
1082 for flavour in flavours:
1083 if flavour == 'yes':
1085 possible_manifests.append(
1086 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1088 return [path for path in possible_manifests if os.path.isfile(path)]
1091 def fetch_real_name(app_dir, flavours):
1092 '''Retrieve the package name. Returns the name, or None if not found.'''
1093 for path in manifest_paths(app_dir, flavours):
1094 if not has_extension(path, 'xml') or not os.path.isfile(path):
1096 logging.debug("fetch_real_name: Checking manifest at " + path)
1097 xml = parse_xml(path)
1098 app = xml.find('application')
1101 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1103 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1104 result = retrieve_string_singleline(app_dir, label)
1106 result = result.strip()
1111 def get_library_references(root_dir):
1113 proppath = os.path.join(root_dir, 'project.properties')
1114 if not os.path.isfile(proppath):
1116 with open(proppath, 'r', encoding='iso-8859-1') as f:
1118 if not line.startswith('android.library.reference.'):
1120 path = line.split('=')[1].strip()
1121 relpath = os.path.join(root_dir, path)
1122 if not os.path.isdir(relpath):
1124 logging.debug("Found subproject at %s" % path)
1125 libraries.append(path)
1129 def ant_subprojects(root_dir):
1130 subprojects = get_library_references(root_dir)
1131 for subpath in subprojects:
1132 subrelpath = os.path.join(root_dir, subpath)
1133 for p in get_library_references(subrelpath):
1134 relp = os.path.normpath(os.path.join(subpath, p))
1135 if relp not in subprojects:
1136 subprojects.insert(0, relp)
1140 def remove_debuggable_flags(root_dir):
1141 # Remove forced debuggable flags
1142 logging.debug("Removing debuggable flags from %s" % root_dir)
1143 for root, dirs, files in os.walk(root_dir):
1144 if 'AndroidManifest.xml' in files:
1145 regsub_file(r'android:debuggable="[^"]*"',
1147 os.path.join(root, 'AndroidManifest.xml'))
1150 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1151 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1152 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1155 def app_matches_packagename(app, package):
1158 appid = app.UpdateCheckName or app.id
1159 if appid is None or appid == "Ignore":
1161 return appid == package
1164 def parse_androidmanifests(paths, app):
1166 Extract some information from the AndroidManifest.xml at the given path.
1167 Returns (version, vercode, package), any or all of which might be None.
1168 All values returned are strings.
1171 ignoreversions = app.UpdateCheckIgnore
1172 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1175 return (None, None, None)
1183 if not os.path.isfile(path):
1186 logging.debug("Parsing manifest at {0}".format(path))
1191 if has_extension(path, 'gradle'):
1192 with open(path, 'r') as f:
1194 if gradle_comment.match(line):
1196 # Grab first occurence of each to avoid running into
1197 # alternative flavours and builds.
1199 matches = psearch_g(line)
1201 s = matches.group(2)
1202 if app_matches_packagename(app, s):
1205 matches = vnsearch_g(line)
1207 version = matches.group(2)
1209 matches = vcsearch_g(line)
1211 vercode = matches.group(1)
1214 xml = parse_xml(path)
1215 if "package" in xml.attrib:
1216 s = xml.attrib["package"]
1217 if app_matches_packagename(app, s):
1219 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1220 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1221 base_dir = os.path.dirname(path)
1222 version = retrieve_string_singleline(base_dir, version)
1223 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1224 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1225 if string_is_integer(a):
1228 logging.warning("Problem with xml at {0}".format(path))
1230 # Remember package name, may be defined separately from version+vercode
1232 package = max_package
1234 logging.debug("..got package={0}, version={1}, vercode={2}"
1235 .format(package, version, vercode))
1237 # Always grab the package name and version name in case they are not
1238 # together with the highest version code
1239 if max_package is None and package is not None:
1240 max_package = package
1241 if max_version is None and version is not None:
1242 max_version = version
1244 if vercode is not None \
1245 and (max_vercode is None or vercode > max_vercode):
1246 if not ignoresearch or not ignoresearch(version):
1247 if version is not None:
1248 max_version = version
1249 if vercode is not None:
1250 max_vercode = vercode
1251 if package is not None:
1252 max_package = package
1254 max_version = "Ignore"
1256 if max_version is None:
1257 max_version = "Unknown"
1259 if max_package and not is_valid_package_name(max_package):
1260 raise FDroidException("Invalid package name {0}".format(max_package))
1262 return (max_version, max_vercode, max_package)
1265 def is_valid_package_name(name):
1266 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1269 class FDroidException(Exception):
1271 def __init__(self, value, detail=None):
1273 self.detail = detail
1275 def shortened_detail(self):
1276 if len(self.detail) < 16000:
1278 return '[...]\n' + self.detail[-16000:]
1280 def get_wikitext(self):
1281 ret = repr(self.value) + "\n"
1284 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1290 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1294 class VCSException(FDroidException):
1298 class BuildException(FDroidException):
1302 # Get the specified source library.
1303 # Returns the path to it. Normally this is the path to be used when referencing
1304 # it, which may be a subdirectory of the actual project. If you want the base
1305 # directory of the project, pass 'basepath=True'.
1306 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1307 raw=False, prepare=True, preponly=False, refresh=True,
1316 name, ref = spec.split('@')
1318 number, name = name.split(':', 1)
1320 name, subdir = name.split('/', 1)
1322 if name not in fdroidserver.metadata.srclibs:
1323 raise VCSException('srclib ' + name + ' not found.')
1325 srclib = fdroidserver.metadata.srclibs[name]
1327 sdir = os.path.join(srclib_dir, name)
1330 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1331 vcs.srclib = (name, number, sdir)
1333 vcs.gotorevision(ref, refresh)
1340 libdir = os.path.join(sdir, subdir)
1341 elif srclib["Subdir"]:
1342 for subdir in srclib["Subdir"]:
1343 libdir_candidate = os.path.join(sdir, subdir)
1344 if os.path.exists(libdir_candidate):
1345 libdir = libdir_candidate
1351 remove_signing_keys(sdir)
1352 remove_debuggable_flags(sdir)
1356 if srclib["Prepare"]:
1357 cmd = replace_config_vars(srclib["Prepare"], build)
1359 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1360 if p.returncode != 0:
1361 raise BuildException("Error running prepare command for srclib %s"
1367 return (name, number, libdir)
1370 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1373 # Prepare the source code for a particular build
1374 # 'vcs' - the appropriate vcs object for the application
1375 # 'app' - the application details from the metadata
1376 # 'build' - the build details from the metadata
1377 # 'build_dir' - the path to the build directory, usually
1379 # 'srclib_dir' - the path to the source libraries directory, usually
1381 # 'extlib_dir' - the path to the external libraries directory, usually
1383 # Returns the (root, srclibpaths) where:
1384 # 'root' is the root directory, which may be the same as 'build_dir' or may
1385 # be a subdirectory of it.
1386 # 'srclibpaths' is information on the srclibs being used
1387 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1389 # Optionally, the actual app source can be in a subdirectory
1391 root_dir = os.path.join(build_dir, build.subdir)
1393 root_dir = build_dir
1395 # Get a working copy of the right revision
1396 logging.info("Getting source for revision " + build.commit)
1397 vcs.gotorevision(build.commit, refresh)
1399 # Initialise submodules if required
1400 if build.submodules:
1401 logging.info("Initialising submodules")
1402 vcs.initsubmodules()
1404 # Check that a subdir (if we're using one) exists. This has to happen
1405 # after the checkout, since it might not exist elsewhere
1406 if not os.path.exists(root_dir):
1407 raise BuildException('Missing subdir ' + root_dir)
1409 # Run an init command if one is required
1411 cmd = replace_config_vars(build.init, build)
1412 logging.info("Running 'init' commands in %s" % root_dir)
1414 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1415 if p.returncode != 0:
1416 raise BuildException("Error running init command for %s:%s" %
1417 (app.id, build.versionName), p.output)
1419 # Apply patches if any
1421 logging.info("Applying patches")
1422 for patch in build.patch:
1423 patch = patch.strip()
1424 logging.info("Applying " + patch)
1425 patch_path = os.path.join('metadata', app.id, patch)
1426 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1427 if p.returncode != 0:
1428 raise BuildException("Failed to apply patch %s" % patch_path)
1430 # Get required source libraries
1433 logging.info("Collecting source libraries")
1434 for lib in build.srclibs:
1435 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1436 refresh=refresh, build=build))
1438 for name, number, libpath in srclibpaths:
1439 place_srclib(root_dir, int(number) if number else None, libpath)
1441 basesrclib = vcs.getsrclib()
1442 # If one was used for the main source, add that too.
1444 srclibpaths.append(basesrclib)
1446 # Update the local.properties file
1447 localprops = [os.path.join(build_dir, 'local.properties')]
1449 parts = build.subdir.split(os.sep)
1452 cur = os.path.join(cur, d)
1453 localprops += [os.path.join(cur, 'local.properties')]
1454 for path in localprops:
1456 if os.path.isfile(path):
1457 logging.info("Updating local.properties file at %s" % path)
1458 with open(path, 'r', encoding='iso-8859-1') as f:
1462 logging.info("Creating local.properties file at %s" % path)
1463 # Fix old-fashioned 'sdk-location' by copying
1464 # from sdk.dir, if necessary
1466 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1467 re.S | re.M).group(1)
1468 props += "sdk-location=%s\n" % sdkloc
1470 props += "sdk.dir=%s\n" % config['sdk_path']
1471 props += "sdk-location=%s\n" % config['sdk_path']
1472 ndk_path = build.ndk_path()
1473 # if for any reason the path isn't valid or the directory
1474 # doesn't exist, some versions of Gradle will error with a
1475 # cryptic message (even if the NDK is not even necessary).
1476 # https://gitlab.com/fdroid/fdroidserver/issues/171
1477 if ndk_path and os.path.exists(ndk_path):
1479 props += "ndk.dir=%s\n" % ndk_path
1480 props += "ndk-location=%s\n" % ndk_path
1481 # Add java.encoding if necessary
1483 props += "java.encoding=%s\n" % build.encoding
1484 with open(path, 'w', encoding='iso-8859-1') as f:
1488 if build.build_method() == 'gradle':
1489 flavours = build.gradle
1492 n = build.target.split('-')[1]
1493 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1494 r'compileSdkVersion %s' % n,
1495 os.path.join(root_dir, 'build.gradle'))
1497 # Remove forced debuggable flags
1498 remove_debuggable_flags(root_dir)
1500 # Insert version code and number into the manifest if necessary
1501 if build.forceversion:
1502 logging.info("Changing the version name")
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:versionName="[^"]*"',
1508 r'android:versionName="%s"' % build.versionName,
1510 elif has_extension(path, 'gradle'):
1511 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1512 r"""\1versionName '%s'""" % build.versionName,
1515 if build.forcevercode:
1516 logging.info("Changing the version code")
1517 for path in manifest_paths(root_dir, flavours):
1518 if not os.path.isfile(path):
1520 if has_extension(path, 'xml'):
1521 regsub_file(r'android:versionCode="[^"]*"',
1522 r'android:versionCode="%s"' % build.versionCode,
1524 elif has_extension(path, 'gradle'):
1525 regsub_file(r'versionCode[ =]+[0-9]+',
1526 r'versionCode %s' % build.versionCode,
1529 # Delete unwanted files
1531 logging.info("Removing specified files")
1532 for part in getpaths(build_dir, build.rm):
1533 dest = os.path.join(build_dir, part)
1534 logging.info("Removing {0}".format(part))
1535 if os.path.lexists(dest):
1536 if os.path.islink(dest):
1537 FDroidPopen(['unlink', dest], output=False)
1539 FDroidPopen(['rm', '-rf', dest], output=False)
1541 logging.info("...but it didn't exist")
1543 remove_signing_keys(build_dir)
1545 # Add required external libraries
1547 logging.info("Collecting prebuilt libraries")
1548 libsdir = os.path.join(root_dir, 'libs')
1549 if not os.path.exists(libsdir):
1551 for lib in build.extlibs:
1553 logging.info("...installing extlib {0}".format(lib))
1554 libf = os.path.basename(lib)
1555 libsrc = os.path.join(extlib_dir, lib)
1556 if not os.path.exists(libsrc):
1557 raise BuildException("Missing extlib file {0}".format(libsrc))
1558 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1560 # Run a pre-build command if one is required
1562 logging.info("Running 'prebuild' commands in %s" % root_dir)
1564 cmd = replace_config_vars(build.prebuild, build)
1566 # Substitute source library paths into prebuild commands
1567 for name, number, libpath in srclibpaths:
1568 libpath = os.path.relpath(libpath, root_dir)
1569 cmd = cmd.replace('$$' + name + '$$', libpath)
1571 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1572 if p.returncode != 0:
1573 raise BuildException("Error running prebuild command for %s:%s" %
1574 (app.id, build.versionName), p.output)
1576 # Generate (or update) the ant build file, build.xml...
1577 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1578 parms = ['android', 'update', 'lib-project']
1579 lparms = ['android', 'update', 'project']
1582 parms += ['-t', build.target]
1583 lparms += ['-t', build.target]
1584 if build.androidupdate:
1585 update_dirs = build.androidupdate
1587 update_dirs = ant_subprojects(root_dir) + ['.']
1589 for d in update_dirs:
1590 subdir = os.path.join(root_dir, d)
1592 logging.debug("Updating main project")
1593 cmd = parms + ['-p', d]
1595 logging.debug("Updating subproject %s" % d)
1596 cmd = lparms + ['-p', d]
1597 p = SdkToolsPopen(cmd, cwd=root_dir)
1598 # Check to see whether an error was returned without a proper exit
1599 # code (this is the case for the 'no target set or target invalid'
1601 if p.returncode != 0 or p.output.startswith("Error: "):
1602 raise BuildException("Failed to update project at %s" % d, p.output)
1603 # Clean update dirs via ant
1605 logging.info("Cleaning subproject %s" % d)
1606 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1608 return (root_dir, srclibpaths)
1611 # Extend via globbing the paths from a field and return them as a map from
1612 # original path to resulting paths
1613 def getpaths_map(build_dir, globpaths):
1617 full_path = os.path.join(build_dir, p)
1618 full_path = os.path.normpath(full_path)
1619 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1621 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1625 # Extend via globbing the paths from a field and return them as a set
1626 def getpaths(build_dir, globpaths):
1627 paths_map = getpaths_map(build_dir, globpaths)
1629 for k, v in paths_map.items():
1636 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1642 self.path = os.path.join('stats', 'known_apks.txt')
1644 if os.path.isfile(self.path):
1645 with open(self.path, 'r', encoding='utf8') as f:
1647 t = line.rstrip().split(' ')
1649 self.apks[t[0]] = (t[1], None)
1651 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1652 self.changed = False
1654 def writeifchanged(self):
1655 if not self.changed:
1658 if not os.path.exists('stats'):
1662 for apk, app in self.apks.items():
1664 line = apk + ' ' + appid
1666 line += ' ' + added.strftime('%Y-%m-%d')
1669 with open(self.path, 'w', encoding='utf8') as f:
1670 for line in sorted(lst, key=natural_key):
1671 f.write(line + '\n')
1673 def recordapk(self, apk, app, default_date=None):
1675 Record an apk (if it's new, otherwise does nothing)
1676 Returns the date it was added as a datetime instance
1678 if apk not in self.apks:
1679 if default_date is None:
1680 default_date = datetime.utcnow()
1681 self.apks[apk] = (app, default_date)
1683 _, added = self.apks[apk]
1686 # Look up information - given the 'apkname', returns (app id, date added/None).
1687 # Or returns None for an unknown apk.
1688 def getapp(self, apkname):
1689 if apkname in self.apks:
1690 return self.apks[apkname]
1693 # Get the most recent 'num' apps added to the repo, as a list of package ids
1694 # with the most recent first.
1695 def getlatest(self, num):
1697 for apk, app in self.apks.items():
1701 if apps[appid] > added:
1705 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1706 lst = [app for app, _ in sortedapps]
1711 def get_file_extension(filename):
1712 """get the normalized file extension, can be blank string but never None"""
1714 return os.path.splitext(filename)[1].lower()[1:]
1717 def isApkAndDebuggable(apkfile, config):
1718 """Returns True if the given file is an APK and is debuggable
1720 :param apkfile: full path to the apk to check"""
1722 if get_file_extension(apkfile) != 'apk':
1725 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1727 if p.returncode != 0:
1728 logging.critical("Failed to get apk manifest information")
1730 for line in p.output.splitlines():
1731 if 'android:debuggable' in line and not line.endswith('0x0'):
1738 self.returncode = None
1742 def SdkToolsPopen(commands, cwd=None, output=True):
1744 if cmd not in config:
1745 config[cmd] = find_sdk_tools_cmd(commands[0])
1746 abscmd = config[cmd]
1748 logging.critical("Could not find '%s' on your system" % cmd)
1751 test_aapt_version(config['aapt'])
1752 return FDroidPopen([abscmd] + commands[1:],
1753 cwd=cwd, output=output)
1756 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1758 Run a command and capture the possibly huge output as bytes.
1760 :param commands: command and argument list like in subprocess.Popen
1761 :param cwd: optionally specifies a working directory
1762 :returns: A PopenResult.
1767 set_FDroidPopen_env()
1770 cwd = os.path.normpath(cwd)
1771 logging.debug("Directory: %s" % cwd)
1772 logging.debug("> %s" % ' '.join(commands))
1774 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1775 result = PopenResult()
1778 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1779 stdout=subprocess.PIPE, stderr=stderr_param)
1780 except OSError as e:
1781 raise BuildException("OSError while trying to execute " +
1782 ' '.join(commands) + ': ' + str(e))
1784 if not stderr_to_stdout and options.verbose:
1785 stderr_queue = Queue()
1786 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1788 while not stderr_reader.eof():
1789 while not stderr_queue.empty():
1790 line = stderr_queue.get()
1791 sys.stderr.buffer.write(line)
1796 stdout_queue = Queue()
1797 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1800 # Check the queue for output (until there is no more to get)
1801 while not stdout_reader.eof():
1802 while not stdout_queue.empty():
1803 line = stdout_queue.get()
1804 if output and options.verbose:
1805 # Output directly to console
1806 sys.stderr.buffer.write(line)
1812 result.returncode = p.wait()
1813 result.output = buf.getvalue()
1818 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1820 Run a command and capture the possibly huge output as a str.
1822 :param commands: command and argument list like in subprocess.Popen
1823 :param cwd: optionally specifies a working directory
1824 :returns: A PopenResult.
1826 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1827 result.output = result.output.decode('utf-8', 'ignore')
1831 gradle_comment = re.compile(r'[ ]*//')
1832 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1833 gradle_line_matches = [
1834 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1835 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1836 re.compile(r'.*\.readLine\(.*'),
1840 def remove_signing_keys(build_dir):
1841 for root, dirs, files in os.walk(build_dir):
1842 if 'build.gradle' in files:
1843 path = os.path.join(root, 'build.gradle')
1845 with open(path, "r", encoding='utf8') as o:
1846 lines = o.readlines()
1852 with open(path, "w", encoding='utf8') as o:
1853 while i < len(lines):
1856 while line.endswith('\\\n'):
1857 line = line.rstrip('\\\n') + lines[i]
1860 if gradle_comment.match(line):
1865 opened += line.count('{')
1866 opened -= line.count('}')
1869 if gradle_signing_configs.match(line):
1874 if any(s.match(line) for s in gradle_line_matches):
1882 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1885 'project.properties',
1887 'default.properties',
1888 'ant.properties', ]:
1889 if propfile in files:
1890 path = os.path.join(root, propfile)
1892 with open(path, "r", encoding='iso-8859-1') as o:
1893 lines = o.readlines()
1897 with open(path, "w", encoding='iso-8859-1') as o:
1899 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1906 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1909 def set_FDroidPopen_env(build=None):
1911 set up the environment variables for the build environment
1913 There is only a weak standard, the variables used by gradle, so also set
1914 up the most commonly used environment variables for SDK and NDK. Also, if
1915 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1917 global env, orig_path
1921 orig_path = env['PATH']
1922 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1923 env[n] = config['sdk_path']
1924 for k, v in config['java_paths'].items():
1925 env['JAVA%s_HOME' % k] = v
1927 missinglocale = True
1928 for k, v in env.items():
1929 if k == 'LANG' and v != 'C':
1930 missinglocale = False
1932 missinglocale = False
1934 env['LANG'] = 'en_US.UTF-8'
1936 if build is not None:
1937 path = build.ndk_path()
1938 paths = orig_path.split(os.pathsep)
1939 if path not in paths:
1940 paths = [path] + paths
1941 env['PATH'] = os.pathsep.join(paths)
1942 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1943 env[n] = build.ndk_path()
1946 def replace_build_vars(cmd, build):
1947 cmd = cmd.replace('$$COMMIT$$', build.commit)
1948 cmd = cmd.replace('$$VERSION$$', build.versionName)
1949 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1953 def replace_config_vars(cmd, build):
1954 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1955 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1956 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1957 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1958 if build is not None:
1959 cmd = replace_build_vars(cmd, build)
1963 def place_srclib(root_dir, number, libpath):
1966 relpath = os.path.relpath(libpath, root_dir)
1967 proppath = os.path.join(root_dir, 'project.properties')
1970 if os.path.isfile(proppath):
1971 with open(proppath, "r", encoding='iso-8859-1') as o:
1972 lines = o.readlines()
1974 with open(proppath, "w", encoding='iso-8859-1') as o:
1977 if line.startswith('android.library.reference.%d=' % number):
1978 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1983 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1986 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1989 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1990 """Verify that two apks are the same
1992 One of the inputs is signed, the other is unsigned. The signature metadata
1993 is transferred from the signed to the unsigned apk, and then jarsigner is
1994 used to verify that the signature from the signed apk is also varlid for
1996 :param signed_apk: Path to a signed apk file
1997 :param unsigned_apk: Path to an unsigned apk file expected to match it
1998 :param tmp_dir: Path to directory for temporary files
1999 :returns: None if the verification is successful, otherwise a string
2000 describing what went wrong.
2002 with ZipFile(signed_apk) as signed_apk_as_zip:
2003 meta_inf_files = ['META-INF/MANIFEST.MF']
2004 for f in signed_apk_as_zip.namelist():
2005 if apk_sigfile.match(f):
2006 meta_inf_files.append(f)
2007 if len(meta_inf_files) < 3:
2008 return "Signature files missing from {0}".format(signed_apk)
2009 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
2010 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
2011 for meta_inf_file in meta_inf_files:
2012 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
2014 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
2015 logging.info("...NOT verified - {0}".format(signed_apk))
2016 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
2017 logging.info("...successfully verified")
2021 apk_badchars = re.compile('''[/ :;'"]''')
2024 def compare_apks(apk1, apk2, tmp_dir):
2027 Returns None if the apk content is the same (apart from the signing key),
2028 otherwise a string describing what's different, or what went wrong when
2029 trying to do the comparison.
2032 absapk1 = os.path.abspath(apk1)
2033 absapk2 = os.path.abspath(apk2)
2035 # try to find diffoscope in the path, if it hasn't been manually configed
2036 if 'diffoscope' not in config:
2037 tmp = find_command('diffoscope')
2039 config['diffoscope'] = tmp
2040 if 'diffoscope' in config:
2041 htmlfile = absapk1 + '.diffoscope.html'
2042 textfile = absapk1 + '.diffoscope.txt'
2043 if subprocess.call([config['diffoscope'],
2044 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2045 '--html', htmlfile, '--text', textfile,
2046 absapk1, absapk2]) != 0:
2047 return("Failed to unpack " + apk1)
2049 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2050 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2051 for d in [apk1dir, apk2dir]:
2052 if os.path.exists(d):
2055 os.mkdir(os.path.join(d, 'jar-xf'))
2057 if subprocess.call(['jar', 'xf',
2058 os.path.abspath(apk1)],
2059 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2060 return("Failed to unpack " + apk1)
2061 if subprocess.call(['jar', 'xf',
2062 os.path.abspath(apk2)],
2063 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2064 return("Failed to unpack " + apk2)
2066 # try to find apktool in the path, if it hasn't been manually configed
2067 if 'apktool' not in config:
2068 tmp = find_command('apktool')
2070 config['apktool'] = tmp
2071 if 'apktool' in config:
2072 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2074 return("Failed to unpack " + apk1)
2075 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2077 return("Failed to unpack " + apk2)
2079 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2080 lines = p.output.splitlines()
2081 if len(lines) != 1 or 'META-INF' not in lines[0]:
2082 meld = find_command('meld')
2083 if meld is not None:
2084 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2085 return("Unexpected diff output - " + p.output)
2087 # since everything verifies, delete the comparison to keep cruft down
2088 shutil.rmtree(apk1dir)
2089 shutil.rmtree(apk2dir)
2091 # If we get here, it seems like they're the same!
2095 def find_command(command):
2096 '''find the full path of a command, or None if it can't be found in the PATH'''
2099 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2101 fpath, fname = os.path.split(command)
2106 for path in os.environ["PATH"].split(os.pathsep):
2107 path = path.strip('"')
2108 exe_file = os.path.join(path, command)
2109 if is_exe(exe_file):
2116 '''generate a random password for when generating keys'''
2117 h = hashlib.sha256()
2118 h.update(os.urandom(16)) # salt
2119 h.update(socket.getfqdn().encode('utf-8'))
2120 passwd = base64.b64encode(h.digest()).strip()
2121 return passwd.decode('utf-8')
2124 def genkeystore(localconfig):
2125 '''Generate a new key with random passwords and add it to new keystore'''
2126 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2127 keystoredir = os.path.dirname(localconfig['keystore'])
2128 if keystoredir is None or keystoredir == '':
2129 keystoredir = os.path.join(os.getcwd(), keystoredir)
2130 if not os.path.exists(keystoredir):
2131 os.makedirs(keystoredir, mode=0o700)
2133 write_password_file("keystorepass", localconfig['keystorepass'])
2134 write_password_file("keypass", localconfig['keypass'])
2135 p = FDroidPopen([config['keytool'], '-genkey',
2136 '-keystore', localconfig['keystore'],
2137 '-alias', localconfig['repo_keyalias'],
2138 '-keyalg', 'RSA', '-keysize', '4096',
2139 '-sigalg', 'SHA256withRSA',
2140 '-validity', '10000',
2141 '-storepass:file', config['keystorepassfile'],
2142 '-keypass:file', config['keypassfile'],
2143 '-dname', localconfig['keydname']])
2144 # TODO keypass should be sent via stdin
2145 if p.returncode != 0:
2146 raise BuildException("Failed to generate key", p.output)
2147 os.chmod(localconfig['keystore'], 0o0600)
2148 # now show the lovely key that was just generated
2149 p = FDroidPopen([config['keytool'], '-list', '-v',
2150 '-keystore', localconfig['keystore'],
2151 '-alias', localconfig['repo_keyalias'],
2152 '-storepass:file', config['keystorepassfile']])
2153 logging.info(p.output.strip() + '\n\n')
2156 def write_to_config(thisconfig, key, value=None):
2157 '''write a key/value to the local config.py'''
2159 origkey = key + '_orig'
2160 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2161 with open('config.py', 'r', encoding='utf8') as f:
2163 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2164 repl = '\n' + key + ' = "' + value + '"'
2165 data = re.sub(pattern, repl, data)
2166 # if this key is not in the file, append it
2167 if not re.match('\s*' + key + '\s*=\s*"', data):
2169 # make sure the file ends with a carraige return
2170 if not re.match('\n$', data):
2172 with open('config.py', 'w', encoding='utf8') as f:
2176 def parse_xml(path):
2177 return XMLElementTree.parse(path).getroot()
2180 def string_is_integer(string):
2188 def get_per_app_repos():
2189 '''per-app repos are dirs named with the packageName of a single app'''
2191 # Android packageNames are Java packages, they may contain uppercase or
2192 # lowercase letters ('A' through 'Z'), numbers, and underscores
2193 # ('_'). However, individual package name parts may only start with
2194 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2195 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2198 for root, dirs, files in os.walk(os.getcwd()):
2200 print('checking', root, 'for', d)
2201 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2202 # standard parts of an fdroid repo, so never packageNames
2205 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2211 def is_repo_file(filename):
2212 '''Whether the file in a repo is a build product to be delivered to users'''
2213 return os.path.isfile(filename) \
2214 and not filename.endswith('.asc') \
2215 and not filename.endswith('.sig') \
2216 and os.path.basename(filename) not in [
2218 'index_unsigned.jar',