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.
38 import xml.etree.ElementTree as XMLElementTree
40 from datetime import datetime
41 from distutils.version import LooseVersion
42 from queue import Queue
43 from zipfile import ZipFile
45 import fdroidserver.metadata
46 from .asynchronousfilereader import AsynchronousFileReader
49 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
58 'sdk_path': "$ANDROID_HOME",
63 'r12b': "$ANDROID_NDK",
68 'build_tools': "25.0.2",
69 'force_build_tools': False,
74 'accepted_formats': ['txt', 'yml'],
75 'sync_from_local_copy_dir': False,
76 'per_app_repos': False,
77 'make_current_version_link': True,
78 'current_version_name_source': 'Name',
79 'update_stats': False,
83 'stats_to_carbon': False,
85 'build_server_always': False,
86 'keystore': 'keystore.jks',
87 'smartcardoptions': [],
93 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
94 'repo_name': "My First FDroid Repo Demo",
95 'repo_icon': "fdroid-icon.png",
96 'repo_description': '''
97 This is a repository of apps to be used with FDroid. Applications in this
98 repository are either official binaries built by the original application
99 developers, or are binaries built from source by the admin of f-droid.org
100 using the tools on https://gitlab.com/u/fdroid.
106 def setup_global_opts(parser):
107 parser.add_argument("-v", "--verbose", action="store_true", default=False,
108 help="Spew out even more information than normal")
109 parser.add_argument("-q", "--quiet", action="store_true", default=False,
110 help="Restrict output to warnings and errors")
113 def fill_config_defaults(thisconfig):
114 for k, v in default_config.items():
115 if k not in thisconfig:
118 # Expand paths (~users and $vars)
119 def expand_path(path):
123 path = os.path.expanduser(path)
124 path = os.path.expandvars(path)
129 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
134 thisconfig[k + '_orig'] = v
136 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
137 if thisconfig['java_paths'] is None:
138 thisconfig['java_paths'] = dict()
140 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
141 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
142 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
143 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
144 if os.getenv('JAVA_HOME') is not None:
145 pathlist.append(os.getenv('JAVA_HOME'))
146 if os.getenv('PROGRAMFILES') is not None:
147 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
148 for d in sorted(pathlist):
149 if os.path.islink(d):
151 j = os.path.basename(d)
152 # the last one found will be the canonical one, so order appropriately
154 r'^1\.([6-9])\.0\.jdk$', # OSX
155 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
156 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
157 r'^jdk([6-9])-openjdk$', # Arch
158 r'^java-([6-9])-openjdk$', # Arch
159 r'^java-([6-9])-jdk$', # Arch (oracle)
160 r'^java-1\.([6-9])\.0-.*$', # RedHat
161 r'^java-([6-9])-oracle$', # Debian WebUpd8
162 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
163 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
165 m = re.match(regex, j)
168 for p in [d, os.path.join(d, 'Contents', 'Home')]:
169 if os.path.exists(os.path.join(p, 'bin', 'javac')):
170 thisconfig['java_paths'][m.group(1)] = p
172 for java_version in ('7', '8', '9'):
173 if java_version not in thisconfig['java_paths']:
175 java_home = thisconfig['java_paths'][java_version]
176 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
177 if os.path.exists(jarsigner):
178 thisconfig['jarsigner'] = jarsigner
179 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
180 break # Java7 is preferred, so quit if found
182 for k in ['ndk_paths', 'java_paths']:
188 thisconfig[k][k2] = exp
189 thisconfig[k][k2 + '_orig'] = v
192 def regsub_file(pattern, repl, path):
193 with open(path, 'rb') as f:
195 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
196 with open(path, 'wb') as f:
200 def read_config(opts, config_file='config.py'):
201 """Read the repository config
203 The config is read from config_file, which is in the current
204 directory when any of the repo management commands are used. If
205 there is a local metadata file in the git repo, then config.py is
206 not required, just use defaults.
209 global config, options
211 if config is not None:
218 if os.path.isfile(config_file):
219 logging.debug("Reading %s" % config_file)
220 with io.open(config_file, "rb") as f:
221 code = compile(f.read(), config_file, 'exec')
222 exec(code, None, config)
223 elif len(get_local_metadata_files()) == 0:
224 logging.critical("Missing config file - is this a repo directory?")
227 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
229 if not type(config[k]) in (str, list, tuple):
230 logging.warn('"' + k + '" will be in random order!'
231 + ' Use () or [] brackets if order is important!')
233 # smartcardoptions must be a list since its command line args for Popen
234 if 'smartcardoptions' in config:
235 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
236 elif 'keystore' in config and config['keystore'] == 'NONE':
237 # keystore='NONE' means use smartcard, these are required defaults
238 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
239 'SunPKCS11-OpenSC', '-providerClass',
240 'sun.security.pkcs11.SunPKCS11',
241 '-providerArg', 'opensc-fdroid.cfg']
243 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
244 st = os.stat(config_file)
245 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
246 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
248 fill_config_defaults(config)
250 for k in ["keystorepass", "keypass"]:
252 write_password_file(k)
254 for k in ["repo_description", "archive_description"]:
256 config[k] = clean_description(config[k])
258 if 'serverwebroot' in config:
259 if isinstance(config['serverwebroot'], str):
260 roots = [config['serverwebroot']]
261 elif all(isinstance(item, str) for item in config['serverwebroot']):
262 roots = config['serverwebroot']
264 raise TypeError('only accepts strings, lists, and tuples')
266 for rootstr in roots:
267 # since this is used with rsync, where trailing slashes have
268 # meaning, ensure there is always a trailing slash
269 if rootstr[-1] != '/':
271 rootlist.append(rootstr.replace('//', '/'))
272 config['serverwebroot'] = rootlist
274 if 'servergitmirrors' in config:
275 if isinstance(config['servergitmirrors'], str):
276 roots = [config['servergitmirrors']]
277 elif all(isinstance(item, str) for item in config['servergitmirrors']):
278 roots = config['servergitmirrors']
280 raise TypeError('only accepts strings, lists, and tuples')
281 config['servergitmirrors'] = roots
286 def find_sdk_tools_cmd(cmd):
287 '''find a working path to a tool from the Android SDK'''
290 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
291 # try to find a working path to this command, in all the recent possible paths
292 if 'build_tools' in config:
293 build_tools = os.path.join(config['sdk_path'], 'build-tools')
294 # if 'build_tools' was manually set and exists, check only that one
295 configed_build_tools = os.path.join(build_tools, config['build_tools'])
296 if os.path.exists(configed_build_tools):
297 tooldirs.append(configed_build_tools)
299 # no configed version, so hunt known paths for it
300 for f in sorted(os.listdir(build_tools), reverse=True):
301 if os.path.isdir(os.path.join(build_tools, f)):
302 tooldirs.append(os.path.join(build_tools, f))
303 tooldirs.append(build_tools)
304 sdk_tools = os.path.join(config['sdk_path'], 'tools')
305 if os.path.exists(sdk_tools):
306 tooldirs.append(sdk_tools)
307 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
308 if os.path.exists(sdk_platform_tools):
309 tooldirs.append(sdk_platform_tools)
310 tooldirs.append('/usr/bin')
312 path = os.path.join(d, cmd)
313 if os.path.isfile(path):
315 test_aapt_version(path)
317 # did not find the command, exit with error message
318 ensure_build_tools_exists(config)
321 def test_aapt_version(aapt):
322 '''Check whether the version of aapt is new enough'''
323 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
324 if output is None or output == '':
325 logging.error(aapt + ' failed to execute!')
327 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
332 # the Debian package has the version string like "v0.2-23.0.2"
333 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
334 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
336 logging.warning('Unknown version of aapt, might cause problems: ' + output)
339 def test_sdk_exists(thisconfig):
340 if 'sdk_path' not in thisconfig:
341 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
342 test_aapt_version(thisconfig['aapt'])
345 logging.error("'sdk_path' not set in config.py!")
347 if thisconfig['sdk_path'] == default_config['sdk_path']:
348 logging.error('No Android SDK found!')
349 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
350 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
352 if not os.path.exists(thisconfig['sdk_path']):
353 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
355 if not os.path.isdir(thisconfig['sdk_path']):
356 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
358 for d in ['build-tools', 'platform-tools', 'tools']:
359 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
360 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
361 thisconfig['sdk_path'], d))
366 def ensure_build_tools_exists(thisconfig):
367 if not test_sdk_exists(thisconfig):
369 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
370 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
371 if not os.path.isdir(versioned_build_tools):
372 logging.critical('Android Build Tools path "'
373 + versioned_build_tools + '" does not exist!')
377 def write_password_file(pwtype, password=None):
379 writes out passwords to a protected file instead of passing passwords as
380 command line argments
382 filename = '.fdroid.' + pwtype + '.txt'
383 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
385 os.write(fd, config[pwtype].encode('utf-8'))
387 os.write(fd, password.encode('utf-8'))
389 config[pwtype + 'file'] = filename
394 sign a JAR file with Java's jarsigner.
396 This does use old hashing algorithms, i.e. SHA1, but that's not
397 broken yet for file verification. This could be set to SHA256,
398 but then Android < 4.3 would not be able to verify it.
399 https://code.google.com/p/android/issues/detail?id=38321
401 args = [config['jarsigner'], '-keystore', config['keystore'],
402 '-storepass:file', config['keystorepassfile'],
403 '-digestalg', 'SHA1', '-sigalg', 'SHA1withRSA',
404 jar, config['repo_keyalias']]
405 if config['keystore'] == 'NONE':
406 args += config['smartcardoptions']
407 else: # smardcards never use -keypass
408 args += ['-keypass:file', config['keypassfile']]
409 p = FDroidPopen(args)
410 if p.returncode != 0:
411 logging.critical("Failed to sign %s!" % jar)
415 def sign_index_v1(repodir, json_name):
417 sign index-v1.json to make index-v1.jar
419 This is a bit different than index.jar: instead of their being index.xml
420 and index_unsigned.jar, the presense of index-v1.json means that there is
421 unsigned data. That file is then stuck into a jar and signed by the
422 signing process. index-v1.json is never published to the repo. It is
423 included in the binary transparency log, if that is enabled.
425 name, ext = get_extension(json_name)
426 index_file = os.path.join(repodir, json_name)
427 jar_file = os.path.join(repodir, name + '.jar')
428 with zipfile.ZipFile(jar_file, 'w', zipfile.ZIP_DEFLATED) as jar:
429 jar.write(index_file, json_name)
433 def get_local_metadata_files():
434 '''get any metadata files local to an app's source repo
436 This tries to ignore anything that does not count as app metdata,
437 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
440 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
443 def read_pkg_args(args, allow_vercodes=False):
445 Given the arguments in the form of multiple appid:[vc] strings, this returns
446 a dictionary with the set of vercodes specified for each package.
454 if allow_vercodes and ':' in p:
455 package, vercode = p.split(':')
457 package, vercode = p, None
458 if package not in vercodes:
459 vercodes[package] = [vercode] if vercode else []
461 elif vercode and vercode not in vercodes[package]:
462 vercodes[package] += [vercode] if vercode else []
467 def read_app_args(args, allapps, allow_vercodes=False):
469 On top of what read_pkg_args does, this returns the whole app metadata, but
470 limiting the builds list to the builds matching the vercodes specified.
473 vercodes = read_pkg_args(args, allow_vercodes)
479 for appid, app in allapps.items():
480 if appid in vercodes:
483 if len(apps) != len(vercodes):
486 logging.critical("No such package: %s" % p)
487 raise FDroidException("Found invalid app ids in arguments")
489 raise FDroidException("No packages specified")
492 for appid, app in apps.items():
496 app.builds = [b for b in app.builds if b.versionCode in vc]
497 if len(app.builds) != len(vercodes[appid]):
499 allvcs = [b.versionCode for b in app.builds]
500 for v in vercodes[appid]:
502 logging.critical("No such vercode %s for app %s" % (v, appid))
505 raise FDroidException("Found invalid vercodes for some apps")
510 def get_extension(filename):
511 base, ext = os.path.splitext(filename)
514 return base, ext.lower()[1:]
517 def has_extension(filename, ext):
518 _, f_ext = get_extension(filename)
522 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
525 def clean_description(description):
526 'Remove unneeded newlines and spaces from a block of description text'
528 # this is split up by paragraph to make removing the newlines easier
529 for paragraph in re.split(r'\n\n', description):
530 paragraph = re.sub('\r', '', paragraph)
531 paragraph = re.sub('\n', ' ', paragraph)
532 paragraph = re.sub(' {2,}', ' ', paragraph)
533 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
534 returnstring += paragraph + '\n\n'
535 return returnstring.rstrip('\n')
538 def publishednameinfo(filename):
539 filename = os.path.basename(filename)
540 m = publish_name_regex.match(filename)
542 result = (m.group(1), m.group(2))
543 except AttributeError:
544 raise FDroidException("Invalid name for published file: %s" % filename)
548 def get_release_filename(app, build):
550 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
552 return "%s_%s.apk" % (app.id, build.versionCode)
555 def get_toolsversion_logname(app, build):
556 return "%s_%s_toolsversion.log" % (app.id, build.versionCode)
559 def getsrcname(app, build):
560 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
572 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
575 def get_build_dir(app):
576 '''get the dir that this app will be built in'''
578 if app.RepoType == 'srclib':
579 return os.path.join('build', 'srclib', app.Repo)
581 return os.path.join('build', app.id)
585 '''checkout code from VCS and return instance of vcs and the build dir'''
586 build_dir = get_build_dir(app)
588 # Set up vcs interface and make sure we have the latest code...
589 logging.debug("Getting {0} vcs interface for {1}"
590 .format(app.RepoType, app.Repo))
591 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
595 vcs = getvcs(app.RepoType, remote, build_dir)
597 return vcs, build_dir
600 def getvcs(vcstype, remote, local):
602 return vcs_git(remote, local)
603 if vcstype == 'git-svn':
604 return vcs_gitsvn(remote, local)
606 return vcs_hg(remote, local)
608 return vcs_bzr(remote, local)
609 if vcstype == 'srclib':
610 if local != os.path.join('build', 'srclib', remote):
611 raise VCSException("Error: srclib paths are hard-coded!")
612 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
614 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
615 raise VCSException("Invalid vcs type " + vcstype)
618 def getsrclibvcs(name):
619 if name not in fdroidserver.metadata.srclibs:
620 raise VCSException("Missing srclib " + name)
621 return fdroidserver.metadata.srclibs[name]['Repo Type']
626 def __init__(self, remote, local):
628 # svn, git-svn and bzr may require auth
630 if self.repotype() in ('git-svn', 'bzr'):
632 if self.repotype == 'git-svn':
633 raise VCSException("Authentication is not supported for git-svn")
634 self.username, remote = remote.split('@')
635 if ':' not in self.username:
636 raise VCSException("Password required with username")
637 self.username, self.password = self.username.split(':')
641 self.clone_failed = False
642 self.refreshed = False
648 # Take the local repository to a clean version of the given revision, which
649 # is specificed in the VCS's native format. Beforehand, the repository can
650 # be dirty, or even non-existent. If the repository does already exist
651 # locally, it will be updated from the origin, but only once in the
652 # lifetime of the vcs object.
653 # None is acceptable for 'rev' if you know you are cloning a clean copy of
654 # the repo - otherwise it must specify a valid revision.
655 def gotorevision(self, rev, refresh=True):
657 if self.clone_failed:
658 raise VCSException("Downloading the repository already failed once, not trying again.")
660 # The .fdroidvcs-id file for a repo tells us what VCS type
661 # and remote that directory was created from, allowing us to drop it
662 # automatically if either of those things changes.
663 fdpath = os.path.join(self.local, '..',
664 '.fdroidvcs-' + os.path.basename(self.local))
665 fdpath = os.path.normpath(fdpath)
666 cdata = self.repotype() + ' ' + self.remote
669 if os.path.exists(self.local):
670 if os.path.exists(fdpath):
671 with open(fdpath, 'r') as f:
672 fsdata = f.read().strip()
677 logging.info("Repository details for %s changed - deleting" % (
681 logging.info("Repository details for %s missing - deleting" % (
684 shutil.rmtree(self.local)
688 self.refreshed = True
691 self.gotorevisionx(rev)
692 except FDroidException as e:
695 # If necessary, write the .fdroidvcs file.
696 if writeback and not self.clone_failed:
697 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
698 with open(fdpath, 'w+') as f:
704 # Derived classes need to implement this. It's called once basic checking
705 # has been performend.
706 def gotorevisionx(self, rev):
707 raise VCSException("This VCS type doesn't define gotorevisionx")
709 # Initialise and update submodules
710 def initsubmodules(self):
711 raise VCSException('Submodules not supported for this vcs type')
713 # Get a list of all known tags
715 if not self._gettags:
716 raise VCSException('gettags not supported for this vcs type')
718 for tag in self._gettags():
719 if re.match('[-A-Za-z0-9_. /]+$', tag):
723 # Get a list of all the known tags, sorted from newest to oldest
724 def latesttags(self):
725 raise VCSException('latesttags not supported for this vcs type')
727 # Get current commit reference (hash, revision, etc)
729 raise VCSException('getref not supported for this vcs type')
731 # Returns the srclib (name, path) used in setting up the current
742 # If the local directory exists, but is somehow not a git repository, git
743 # will traverse up the directory tree until it finds one that is (i.e.
744 # fdroidserver) and then we'll proceed to destroy it! This is called as
747 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
748 result = p.output.rstrip()
749 if not result.endswith(self.local):
750 raise VCSException('Repository mismatch')
752 def gotorevisionx(self, rev):
753 if not os.path.exists(self.local):
755 p = FDroidPopen(['git', 'clone', self.remote, self.local])
756 if p.returncode != 0:
757 self.clone_failed = True
758 raise VCSException("Git clone failed", p.output)
762 # Discard any working tree changes
763 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
764 'git', 'reset', '--hard'], cwd=self.local, output=False)
765 if p.returncode != 0:
766 raise VCSException("Git reset failed", p.output)
767 # Remove untracked files now, in case they're tracked in the target
768 # revision (it happens!)
769 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
770 'git', 'clean', '-dffx'], cwd=self.local, output=False)
771 if p.returncode != 0:
772 raise VCSException("Git clean failed", p.output)
773 if not self.refreshed:
774 # Get latest commits and tags from remote
775 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
776 if p.returncode != 0:
777 raise VCSException("Git fetch failed", p.output)
778 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
779 if p.returncode != 0:
780 raise VCSException("Git fetch failed", p.output)
781 # Recreate origin/HEAD as git clone would do it, in case it disappeared
782 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
783 if p.returncode != 0:
784 lines = p.output.splitlines()
785 if 'Multiple remote HEAD branches' not in lines[0]:
786 raise VCSException("Git remote set-head failed", p.output)
787 branch = lines[1].split(' ')[-1]
788 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
789 if p2.returncode != 0:
790 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
791 self.refreshed = True
792 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
793 # a github repo. Most of the time this is the same as origin/master.
794 rev = rev or 'origin/HEAD'
795 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
796 if p.returncode != 0:
797 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
798 # Get rid of any uncontrolled files left behind
799 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
800 if p.returncode != 0:
801 raise VCSException("Git clean failed", p.output)
803 def initsubmodules(self):
805 submfile = os.path.join(self.local, '.gitmodules')
806 if not os.path.isfile(submfile):
807 raise VCSException("No git submodules available")
809 # fix submodules not accessible without an account and public key auth
810 with open(submfile, 'r') as f:
811 lines = f.readlines()
812 with open(submfile, 'w') as f:
814 if 'git@github.com' in line:
815 line = line.replace('git@github.com:', 'https://github.com/')
816 if 'git@gitlab.com' in line:
817 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
820 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
821 if p.returncode != 0:
822 raise VCSException("Git submodule sync failed", p.output)
823 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
824 if p.returncode != 0:
825 raise VCSException("Git submodule update failed", p.output)
829 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
830 return p.output.splitlines()
832 tag_format = re.compile(r'tag: ([^),]*)')
834 def latesttags(self):
836 p = FDroidPopen(['git', 'log', '--tags',
837 '--simplify-by-decoration', '--pretty=format:%d'],
838 cwd=self.local, output=False)
840 for line in p.output.splitlines():
841 for tag in self.tag_format.findall(line):
846 class vcs_gitsvn(vcs):
851 # If the local directory exists, but is somehow not a git repository, git
852 # will traverse up the directory tree until it finds one that is (i.e.
853 # fdroidserver) and then we'll proceed to destory it! This is called as
856 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
857 result = p.output.rstrip()
858 if not result.endswith(self.local):
859 raise VCSException('Repository mismatch')
861 def gotorevisionx(self, rev):
862 if not os.path.exists(self.local):
864 gitsvn_args = ['git', 'svn', 'clone']
865 if ';' in self.remote:
866 remote_split = self.remote.split(';')
867 for i in remote_split[1:]:
868 if i.startswith('trunk='):
869 gitsvn_args.extend(['-T', i[6:]])
870 elif i.startswith('tags='):
871 gitsvn_args.extend(['-t', i[5:]])
872 elif i.startswith('branches='):
873 gitsvn_args.extend(['-b', i[9:]])
874 gitsvn_args.extend([remote_split[0], self.local])
875 p = FDroidPopen(gitsvn_args, output=False)
876 if p.returncode != 0:
877 self.clone_failed = True
878 raise VCSException("Git svn clone failed", p.output)
880 gitsvn_args.extend([self.remote, self.local])
881 p = FDroidPopen(gitsvn_args, output=False)
882 if p.returncode != 0:
883 self.clone_failed = True
884 raise VCSException("Git svn clone failed", p.output)
888 # Discard any working tree changes
889 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
890 if p.returncode != 0:
891 raise VCSException("Git reset failed", p.output)
892 # Remove untracked files now, in case they're tracked in the target
893 # revision (it happens!)
894 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
895 if p.returncode != 0:
896 raise VCSException("Git clean failed", p.output)
897 if not self.refreshed:
898 # Get new commits, branches and tags from repo
899 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
900 if p.returncode != 0:
901 raise VCSException("Git svn fetch failed")
902 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
903 if p.returncode != 0:
904 raise VCSException("Git svn rebase failed", p.output)
905 self.refreshed = True
907 rev = rev or 'master'
909 nospaces_rev = rev.replace(' ', '%20')
910 # Try finding a svn tag
911 for treeish in ['origin/', '']:
912 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
913 if p.returncode == 0:
915 if p.returncode != 0:
916 # No tag found, normal svn rev translation
917 # Translate svn rev into git format
918 rev_split = rev.split('/')
921 for treeish in ['origin/', '']:
922 if len(rev_split) > 1:
923 treeish += rev_split[0]
924 svn_rev = rev_split[1]
927 # if no branch is specified, then assume trunk (i.e. 'master' branch):
931 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
933 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
934 git_rev = p.output.rstrip()
936 if p.returncode == 0 and git_rev:
939 if p.returncode != 0 or not git_rev:
940 # Try a plain git checkout as a last resort
941 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
942 if p.returncode != 0:
943 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
945 # Check out the git rev equivalent to the svn rev
946 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
947 if p.returncode != 0:
948 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
950 # Get rid of any uncontrolled files left behind
951 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
952 if p.returncode != 0:
953 raise VCSException("Git clean failed", p.output)
957 for treeish in ['origin/', '']:
958 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
964 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
965 if p.returncode != 0:
967 return p.output.strip()
975 def gotorevisionx(self, rev):
976 if not os.path.exists(self.local):
977 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
978 if p.returncode != 0:
979 self.clone_failed = True
980 raise VCSException("Hg clone failed", p.output)
982 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
983 if p.returncode != 0:
984 raise VCSException("Hg status failed", p.output)
985 for line in p.output.splitlines():
986 if not line.startswith('? '):
987 raise VCSException("Unexpected output from hg status -uS: " + line)
988 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
989 if not self.refreshed:
990 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
991 if p.returncode != 0:
992 raise VCSException("Hg pull failed", p.output)
993 self.refreshed = True
995 rev = rev or 'default'
998 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
999 if p.returncode != 0:
1000 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
1001 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1002 # Also delete untracked files, we have to enable purge extension for that:
1003 if "'purge' is provided by the following extension" in p.output:
1004 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
1005 myfile.write("\n[extensions]\nhgext.purge=\n")
1006 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1007 if p.returncode != 0:
1008 raise VCSException("HG purge failed", p.output)
1009 elif p.returncode != 0:
1010 raise VCSException("HG purge failed", p.output)
1013 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
1014 return p.output.splitlines()[1:]
1022 def gotorevisionx(self, rev):
1023 if not os.path.exists(self.local):
1024 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
1025 if p.returncode != 0:
1026 self.clone_failed = True
1027 raise VCSException("Bzr branch failed", p.output)
1029 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1030 if p.returncode != 0:
1031 raise VCSException("Bzr revert failed", p.output)
1032 if not self.refreshed:
1033 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1034 if p.returncode != 0:
1035 raise VCSException("Bzr update failed", p.output)
1036 self.refreshed = True
1038 revargs = list(['-r', rev] if rev else [])
1039 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1040 if p.returncode != 0:
1041 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1044 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1045 return [tag.split(' ')[0].strip() for tag in
1046 p.output.splitlines()]
1049 def unescape_string(string):
1052 if string[0] == '"' and string[-1] == '"':
1055 return string.replace("\\'", "'")
1058 def retrieve_string(app_dir, string, xmlfiles=None):
1060 if not string.startswith('@string/'):
1061 return unescape_string(string)
1063 if xmlfiles is None:
1066 os.path.join(app_dir, 'res'),
1067 os.path.join(app_dir, 'src', 'main', 'res'),
1069 for r, d, f in os.walk(res_dir):
1070 if os.path.basename(r) == 'values':
1071 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1073 name = string[len('@string/'):]
1075 def element_content(element):
1076 if element.text is None:
1078 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1079 return s.decode('utf-8').strip()
1081 for path in xmlfiles:
1082 if not os.path.isfile(path):
1084 xml = parse_xml(path)
1085 element = xml.find('string[@name="' + name + '"]')
1086 if element is not None:
1087 content = element_content(element)
1088 return retrieve_string(app_dir, content, xmlfiles)
1093 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1094 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1097 def manifest_paths(app_dir, flavours):
1098 '''Return list of existing files that will be used to find the highest vercode'''
1100 possible_manifests = \
1101 [os.path.join(app_dir, 'AndroidManifest.xml'),
1102 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1103 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1104 os.path.join(app_dir, 'build.gradle')]
1106 for flavour in flavours:
1107 if flavour == 'yes':
1109 possible_manifests.append(
1110 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1112 return [path for path in possible_manifests if os.path.isfile(path)]
1115 def fetch_real_name(app_dir, flavours):
1116 '''Retrieve the package name. Returns the name, or None if not found.'''
1117 for path in manifest_paths(app_dir, flavours):
1118 if not has_extension(path, 'xml') or not os.path.isfile(path):
1120 logging.debug("fetch_real_name: Checking manifest at " + path)
1121 xml = parse_xml(path)
1122 app = xml.find('application')
1125 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1127 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1128 result = retrieve_string_singleline(app_dir, label)
1130 result = result.strip()
1135 def get_library_references(root_dir):
1137 proppath = os.path.join(root_dir, 'project.properties')
1138 if not os.path.isfile(proppath):
1140 with open(proppath, 'r', encoding='iso-8859-1') as f:
1142 if not line.startswith('android.library.reference.'):
1144 path = line.split('=')[1].strip()
1145 relpath = os.path.join(root_dir, path)
1146 if not os.path.isdir(relpath):
1148 logging.debug("Found subproject at %s" % path)
1149 libraries.append(path)
1153 def ant_subprojects(root_dir):
1154 subprojects = get_library_references(root_dir)
1155 for subpath in subprojects:
1156 subrelpath = os.path.join(root_dir, subpath)
1157 for p in get_library_references(subrelpath):
1158 relp = os.path.normpath(os.path.join(subpath, p))
1159 if relp not in subprojects:
1160 subprojects.insert(0, relp)
1164 def remove_debuggable_flags(root_dir):
1165 # Remove forced debuggable flags
1166 logging.debug("Removing debuggable flags from %s" % root_dir)
1167 for root, dirs, files in os.walk(root_dir):
1168 if 'AndroidManifest.xml' in files:
1169 regsub_file(r'android:debuggable="[^"]*"',
1171 os.path.join(root, 'AndroidManifest.xml'))
1174 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1175 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1176 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1179 def app_matches_packagename(app, package):
1182 appid = app.UpdateCheckName or app.id
1183 if appid is None or appid == "Ignore":
1185 return appid == package
1188 def parse_androidmanifests(paths, app):
1190 Extract some information from the AndroidManifest.xml at the given path.
1191 Returns (version, vercode, package), any or all of which might be None.
1192 All values returned are strings.
1195 ignoreversions = app.UpdateCheckIgnore
1196 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1199 return (None, None, None)
1207 if not os.path.isfile(path):
1210 logging.debug("Parsing manifest at {0}".format(path))
1215 if has_extension(path, 'gradle'):
1216 with open(path, 'r') as f:
1218 if gradle_comment.match(line):
1220 # Grab first occurence of each to avoid running into
1221 # alternative flavours and builds.
1223 matches = psearch_g(line)
1225 s = matches.group(2)
1226 if app_matches_packagename(app, s):
1229 matches = vnsearch_g(line)
1231 version = matches.group(2)
1233 matches = vcsearch_g(line)
1235 vercode = matches.group(1)
1238 xml = parse_xml(path)
1239 if "package" in xml.attrib:
1240 s = xml.attrib["package"]
1241 if app_matches_packagename(app, s):
1243 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1244 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1245 base_dir = os.path.dirname(path)
1246 version = retrieve_string_singleline(base_dir, version)
1247 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1248 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1249 if string_is_integer(a):
1252 logging.warning("Problem with xml at {0}".format(path))
1254 # Remember package name, may be defined separately from version+vercode
1256 package = max_package
1258 logging.debug("..got package={0}, version={1}, vercode={2}"
1259 .format(package, version, vercode))
1261 # Always grab the package name and version name in case they are not
1262 # together with the highest version code
1263 if max_package is None and package is not None:
1264 max_package = package
1265 if max_version is None and version is not None:
1266 max_version = version
1268 if vercode is not None \
1269 and (max_vercode is None or vercode > max_vercode):
1270 if not ignoresearch or not ignoresearch(version):
1271 if version is not None:
1272 max_version = version
1273 if vercode is not None:
1274 max_vercode = vercode
1275 if package is not None:
1276 max_package = package
1278 max_version = "Ignore"
1280 if max_version is None:
1281 max_version = "Unknown"
1283 if max_package and not is_valid_package_name(max_package):
1284 raise FDroidException("Invalid package name {0}".format(max_package))
1286 return (max_version, max_vercode, max_package)
1289 def is_valid_package_name(name):
1290 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1293 class FDroidException(Exception):
1295 def __init__(self, value, detail=None):
1297 self.detail = detail
1299 def shortened_detail(self):
1300 if len(self.detail) < 16000:
1302 return '[...]\n' + self.detail[-16000:]
1304 def get_wikitext(self):
1305 ret = repr(self.value) + "\n"
1308 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1314 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1318 class VCSException(FDroidException):
1322 class BuildException(FDroidException):
1326 # Get the specified source library.
1327 # Returns the path to it. Normally this is the path to be used when referencing
1328 # it, which may be a subdirectory of the actual project. If you want the base
1329 # directory of the project, pass 'basepath=True'.
1330 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1331 raw=False, prepare=True, preponly=False, refresh=True,
1340 name, ref = spec.split('@')
1342 number, name = name.split(':', 1)
1344 name, subdir = name.split('/', 1)
1346 if name not in fdroidserver.metadata.srclibs:
1347 raise VCSException('srclib ' + name + ' not found.')
1349 srclib = fdroidserver.metadata.srclibs[name]
1351 sdir = os.path.join(srclib_dir, name)
1354 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1355 vcs.srclib = (name, number, sdir)
1357 vcs.gotorevision(ref, refresh)
1364 libdir = os.path.join(sdir, subdir)
1365 elif srclib["Subdir"]:
1366 for subdir in srclib["Subdir"]:
1367 libdir_candidate = os.path.join(sdir, subdir)
1368 if os.path.exists(libdir_candidate):
1369 libdir = libdir_candidate
1375 remove_signing_keys(sdir)
1376 remove_debuggable_flags(sdir)
1380 if srclib["Prepare"]:
1381 cmd = replace_config_vars(srclib["Prepare"], build)
1383 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1384 if p.returncode != 0:
1385 raise BuildException("Error running prepare command for srclib %s"
1391 return (name, number, libdir)
1394 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1397 # Prepare the source code for a particular build
1398 # 'vcs' - the appropriate vcs object for the application
1399 # 'app' - the application details from the metadata
1400 # 'build' - the build details from the metadata
1401 # 'build_dir' - the path to the build directory, usually
1403 # 'srclib_dir' - the path to the source libraries directory, usually
1405 # 'extlib_dir' - the path to the external libraries directory, usually
1407 # Returns the (root, srclibpaths) where:
1408 # 'root' is the root directory, which may be the same as 'build_dir' or may
1409 # be a subdirectory of it.
1410 # 'srclibpaths' is information on the srclibs being used
1411 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1413 # Optionally, the actual app source can be in a subdirectory
1415 root_dir = os.path.join(build_dir, build.subdir)
1417 root_dir = build_dir
1419 # Get a working copy of the right revision
1420 logging.info("Getting source for revision " + build.commit)
1421 vcs.gotorevision(build.commit, refresh)
1423 # Initialise submodules if required
1424 if build.submodules:
1425 logging.info("Initialising submodules")
1426 vcs.initsubmodules()
1428 # Check that a subdir (if we're using one) exists. This has to happen
1429 # after the checkout, since it might not exist elsewhere
1430 if not os.path.exists(root_dir):
1431 raise BuildException('Missing subdir ' + root_dir)
1433 # Run an init command if one is required
1435 cmd = replace_config_vars(build.init, build)
1436 logging.info("Running 'init' commands in %s" % root_dir)
1438 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1439 if p.returncode != 0:
1440 raise BuildException("Error running init command for %s:%s" %
1441 (app.id, build.versionName), p.output)
1443 # Apply patches if any
1445 logging.info("Applying patches")
1446 for patch in build.patch:
1447 patch = patch.strip()
1448 logging.info("Applying " + patch)
1449 patch_path = os.path.join('metadata', app.id, patch)
1450 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1451 if p.returncode != 0:
1452 raise BuildException("Failed to apply patch %s" % patch_path)
1454 # Get required source libraries
1457 logging.info("Collecting source libraries")
1458 for lib in build.srclibs:
1459 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1460 refresh=refresh, build=build))
1462 for name, number, libpath in srclibpaths:
1463 place_srclib(root_dir, int(number) if number else None, libpath)
1465 basesrclib = vcs.getsrclib()
1466 # If one was used for the main source, add that too.
1468 srclibpaths.append(basesrclib)
1470 # Update the local.properties file
1471 localprops = [os.path.join(build_dir, 'local.properties')]
1473 parts = build.subdir.split(os.sep)
1476 cur = os.path.join(cur, d)
1477 localprops += [os.path.join(cur, 'local.properties')]
1478 for path in localprops:
1480 if os.path.isfile(path):
1481 logging.info("Updating local.properties file at %s" % path)
1482 with open(path, 'r', encoding='iso-8859-1') as f:
1486 logging.info("Creating local.properties file at %s" % path)
1487 # Fix old-fashioned 'sdk-location' by copying
1488 # from sdk.dir, if necessary
1490 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1491 re.S | re.M).group(1)
1492 props += "sdk-location=%s\n" % sdkloc
1494 props += "sdk.dir=%s\n" % config['sdk_path']
1495 props += "sdk-location=%s\n" % config['sdk_path']
1496 ndk_path = build.ndk_path()
1497 # if for any reason the path isn't valid or the directory
1498 # doesn't exist, some versions of Gradle will error with a
1499 # cryptic message (even if the NDK is not even necessary).
1500 # https://gitlab.com/fdroid/fdroidserver/issues/171
1501 if ndk_path and os.path.exists(ndk_path):
1503 props += "ndk.dir=%s\n" % ndk_path
1504 props += "ndk-location=%s\n" % ndk_path
1505 # Add java.encoding if necessary
1507 props += "java.encoding=%s\n" % build.encoding
1508 with open(path, 'w', encoding='iso-8859-1') as f:
1512 if build.build_method() == 'gradle':
1513 flavours = build.gradle
1516 n = build.target.split('-')[1]
1517 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1518 r'compileSdkVersion %s' % n,
1519 os.path.join(root_dir, 'build.gradle'))
1521 # Remove forced debuggable flags
1522 remove_debuggable_flags(root_dir)
1524 # Insert version code and number into the manifest if necessary
1525 if build.forceversion:
1526 logging.info("Changing the version name")
1527 for path in manifest_paths(root_dir, flavours):
1528 if not os.path.isfile(path):
1530 if has_extension(path, 'xml'):
1531 regsub_file(r'android:versionName="[^"]*"',
1532 r'android:versionName="%s"' % build.versionName,
1534 elif has_extension(path, 'gradle'):
1535 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1536 r"""\1versionName '%s'""" % build.versionName,
1539 if build.forcevercode:
1540 logging.info("Changing the version code")
1541 for path in manifest_paths(root_dir, flavours):
1542 if not os.path.isfile(path):
1544 if has_extension(path, 'xml'):
1545 regsub_file(r'android:versionCode="[^"]*"',
1546 r'android:versionCode="%s"' % build.versionCode,
1548 elif has_extension(path, 'gradle'):
1549 regsub_file(r'versionCode[ =]+[0-9]+',
1550 r'versionCode %s' % build.versionCode,
1553 # Delete unwanted files
1555 logging.info("Removing specified files")
1556 for part in getpaths(build_dir, build.rm):
1557 dest = os.path.join(build_dir, part)
1558 logging.info("Removing {0}".format(part))
1559 if os.path.lexists(dest):
1560 if os.path.islink(dest):
1561 FDroidPopen(['unlink', dest], output=False)
1563 FDroidPopen(['rm', '-rf', dest], output=False)
1565 logging.info("...but it didn't exist")
1567 remove_signing_keys(build_dir)
1569 # Add required external libraries
1571 logging.info("Collecting prebuilt libraries")
1572 libsdir = os.path.join(root_dir, 'libs')
1573 if not os.path.exists(libsdir):
1575 for lib in build.extlibs:
1577 logging.info("...installing extlib {0}".format(lib))
1578 libf = os.path.basename(lib)
1579 libsrc = os.path.join(extlib_dir, lib)
1580 if not os.path.exists(libsrc):
1581 raise BuildException("Missing extlib file {0}".format(libsrc))
1582 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1584 # Run a pre-build command if one is required
1586 logging.info("Running 'prebuild' commands in %s" % root_dir)
1588 cmd = replace_config_vars(build.prebuild, build)
1590 # Substitute source library paths into prebuild commands
1591 for name, number, libpath in srclibpaths:
1592 libpath = os.path.relpath(libpath, root_dir)
1593 cmd = cmd.replace('$$' + name + '$$', libpath)
1595 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1596 if p.returncode != 0:
1597 raise BuildException("Error running prebuild command for %s:%s" %
1598 (app.id, build.versionName), p.output)
1600 # Generate (or update) the ant build file, build.xml...
1601 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1602 parms = ['android', 'update', 'lib-project']
1603 lparms = ['android', 'update', 'project']
1606 parms += ['-t', build.target]
1607 lparms += ['-t', build.target]
1608 if build.androidupdate:
1609 update_dirs = build.androidupdate
1611 update_dirs = ant_subprojects(root_dir) + ['.']
1613 for d in update_dirs:
1614 subdir = os.path.join(root_dir, d)
1616 logging.debug("Updating main project")
1617 cmd = parms + ['-p', d]
1619 logging.debug("Updating subproject %s" % d)
1620 cmd = lparms + ['-p', d]
1621 p = SdkToolsPopen(cmd, cwd=root_dir)
1622 # Check to see whether an error was returned without a proper exit
1623 # code (this is the case for the 'no target set or target invalid'
1625 if p.returncode != 0 or p.output.startswith("Error: "):
1626 raise BuildException("Failed to update project at %s" % d, p.output)
1627 # Clean update dirs via ant
1629 logging.info("Cleaning subproject %s" % d)
1630 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1632 return (root_dir, srclibpaths)
1635 # Extend via globbing the paths from a field and return them as a map from
1636 # original path to resulting paths
1637 def getpaths_map(build_dir, globpaths):
1641 full_path = os.path.join(build_dir, p)
1642 full_path = os.path.normpath(full_path)
1643 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1645 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1649 # Extend via globbing the paths from a field and return them as a set
1650 def getpaths(build_dir, globpaths):
1651 paths_map = getpaths_map(build_dir, globpaths)
1653 for k, v in paths_map.items():
1660 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1666 self.path = os.path.join('stats', 'known_apks.txt')
1668 if os.path.isfile(self.path):
1669 with open(self.path, 'r', encoding='utf8') as f:
1671 t = line.rstrip().split(' ')
1673 self.apks[t[0]] = (t[1], None)
1675 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1676 self.changed = False
1678 def writeifchanged(self):
1679 if not self.changed:
1682 if not os.path.exists('stats'):
1686 for apk, app in self.apks.items():
1688 line = apk + ' ' + appid
1690 line += ' ' + added.strftime('%Y-%m-%d')
1693 with open(self.path, 'w', encoding='utf8') as f:
1694 for line in sorted(lst, key=natural_key):
1695 f.write(line + '\n')
1697 def recordapk(self, apk, app, default_date=None):
1699 Record an apk (if it's new, otherwise does nothing)
1700 Returns the date it was added as a datetime instance
1702 if apk not in self.apks:
1703 if default_date is None:
1704 default_date = datetime.utcnow()
1705 self.apks[apk] = (app, default_date)
1707 _, added = self.apks[apk]
1710 # Look up information - given the 'apkname', returns (app id, date added/None).
1711 # Or returns None for an unknown apk.
1712 def getapp(self, apkname):
1713 if apkname in self.apks:
1714 return self.apks[apkname]
1717 # Get the most recent 'num' apps added to the repo, as a list of package ids
1718 # with the most recent first.
1719 def getlatest(self, num):
1721 for apk, app in self.apks.items():
1725 if apps[appid] > added:
1729 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1730 lst = [app for app, _ in sortedapps]
1735 def get_file_extension(filename):
1736 """get the normalized file extension, can be blank string but never None"""
1738 return os.path.splitext(filename)[1].lower()[1:]
1741 def isApkAndDebuggable(apkfile, config):
1742 """Returns True if the given file is an APK and is debuggable
1744 :param apkfile: full path to the apk to check"""
1746 if get_file_extension(apkfile) != 'apk':
1749 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1751 if p.returncode != 0:
1752 logging.critical("Failed to get apk manifest information")
1754 for line in p.output.splitlines():
1755 if 'android:debuggable' in line and not line.endswith('0x0'):
1762 self.returncode = None
1766 def SdkToolsPopen(commands, cwd=None, output=True):
1768 if cmd not in config:
1769 config[cmd] = find_sdk_tools_cmd(commands[0])
1770 abscmd = config[cmd]
1772 logging.critical("Could not find '%s' on your system" % cmd)
1775 test_aapt_version(config['aapt'])
1776 return FDroidPopen([abscmd] + commands[1:],
1777 cwd=cwd, output=output)
1780 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1782 Run a command and capture the possibly huge output as bytes.
1784 :param commands: command and argument list like in subprocess.Popen
1785 :param cwd: optionally specifies a working directory
1786 :returns: A PopenResult.
1791 set_FDroidPopen_env()
1794 cwd = os.path.normpath(cwd)
1795 logging.debug("Directory: %s" % cwd)
1796 logging.debug("> %s" % ' '.join(commands))
1798 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1799 result = PopenResult()
1802 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1803 stdout=subprocess.PIPE, stderr=stderr_param)
1804 except OSError as e:
1805 raise BuildException("OSError while trying to execute " +
1806 ' '.join(commands) + ': ' + str(e))
1808 if not stderr_to_stdout and options.verbose:
1809 stderr_queue = Queue()
1810 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1812 while not stderr_reader.eof():
1813 while not stderr_queue.empty():
1814 line = stderr_queue.get()
1815 sys.stderr.buffer.write(line)
1820 stdout_queue = Queue()
1821 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1824 # Check the queue for output (until there is no more to get)
1825 while not stdout_reader.eof():
1826 while not stdout_queue.empty():
1827 line = stdout_queue.get()
1828 if output and options.verbose:
1829 # Output directly to console
1830 sys.stderr.buffer.write(line)
1836 result.returncode = p.wait()
1837 result.output = buf.getvalue()
1842 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1844 Run a command and capture the possibly huge output as a str.
1846 :param commands: command and argument list like in subprocess.Popen
1847 :param cwd: optionally specifies a working directory
1848 :returns: A PopenResult.
1850 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1851 result.output = result.output.decode('utf-8', 'ignore')
1855 gradle_comment = re.compile(r'[ ]*//')
1856 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1857 gradle_line_matches = [
1858 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1859 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1860 re.compile(r'.*\.readLine\(.*'),
1864 def remove_signing_keys(build_dir):
1865 for root, dirs, files in os.walk(build_dir):
1866 if 'build.gradle' in files:
1867 path = os.path.join(root, 'build.gradle')
1869 with open(path, "r", encoding='utf8') as o:
1870 lines = o.readlines()
1876 with open(path, "w", encoding='utf8') as o:
1877 while i < len(lines):
1880 while line.endswith('\\\n'):
1881 line = line.rstrip('\\\n') + lines[i]
1884 if gradle_comment.match(line):
1889 opened += line.count('{')
1890 opened -= line.count('}')
1893 if gradle_signing_configs.match(line):
1898 if any(s.match(line) for s in gradle_line_matches):
1906 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1909 'project.properties',
1911 'default.properties',
1912 'ant.properties', ]:
1913 if propfile in files:
1914 path = os.path.join(root, propfile)
1916 with open(path, "r", encoding='iso-8859-1') as o:
1917 lines = o.readlines()
1921 with open(path, "w", encoding='iso-8859-1') as o:
1923 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1930 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1933 def set_FDroidPopen_env(build=None):
1935 set up the environment variables for the build environment
1937 There is only a weak standard, the variables used by gradle, so also set
1938 up the most commonly used environment variables for SDK and NDK. Also, if
1939 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1941 global env, orig_path
1945 orig_path = env['PATH']
1946 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1947 env[n] = config['sdk_path']
1948 for k, v in config['java_paths'].items():
1949 env['JAVA%s_HOME' % k] = v
1951 missinglocale = True
1952 for k, v in env.items():
1953 if k == 'LANG' and v != 'C':
1954 missinglocale = False
1956 missinglocale = False
1958 env['LANG'] = 'en_US.UTF-8'
1960 if build is not None:
1961 path = build.ndk_path()
1962 paths = orig_path.split(os.pathsep)
1963 if path not in paths:
1964 paths = [path] + paths
1965 env['PATH'] = os.pathsep.join(paths)
1966 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1967 env[n] = build.ndk_path()
1970 def replace_build_vars(cmd, build):
1971 cmd = cmd.replace('$$COMMIT$$', build.commit)
1972 cmd = cmd.replace('$$VERSION$$', build.versionName)
1973 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1977 def replace_config_vars(cmd, build):
1978 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1979 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1980 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1981 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1982 if build is not None:
1983 cmd = replace_build_vars(cmd, build)
1987 def place_srclib(root_dir, number, libpath):
1990 relpath = os.path.relpath(libpath, root_dir)
1991 proppath = os.path.join(root_dir, 'project.properties')
1994 if os.path.isfile(proppath):
1995 with open(proppath, "r", encoding='iso-8859-1') as o:
1996 lines = o.readlines()
1998 with open(proppath, "w", encoding='iso-8859-1') as o:
2001 if line.startswith('android.library.reference.%d=' % number):
2002 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2007 o.write('android.library.reference.%d=%s\n' % (number, relpath))
2010 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2013 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2014 """Verify that two apks are the same
2016 One of the inputs is signed, the other is unsigned. The signature metadata
2017 is transferred from the signed to the unsigned apk, and then jarsigner is
2018 used to verify that the signature from the signed apk is also varlid for
2019 the unsigned one. If the APK given as unsigned actually does have a
2020 signature, it will be stripped out and ignored.
2022 There are two SHA1 git commit IDs that fdroidserver includes in the builds
2023 it makes: fdroidserverid and buildserverid. Originally, these were inserted
2024 into AndroidManifest.xml, but that makes the build not reproducible. So
2025 instead they are included as separate files in the APK's META-INF/ folder.
2026 If those files exist in the signed APK, they will be part of the signature
2027 and need to also be included in the unsigned APK for it to validate.
2029 :param signed_apk: Path to a signed apk file
2030 :param unsigned_apk: Path to an unsigned apk file expected to match it
2031 :param tmp_dir: Path to directory for temporary files
2032 :returns: None if the verification is successful, otherwise a string
2033 describing what went wrong.
2036 signed = ZipFile(signed_apk, 'r')
2037 meta_inf_files = ['META-INF/MANIFEST.MF']
2038 for f in signed.namelist():
2039 if apk_sigfile.match(f) \
2040 or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2041 meta_inf_files.append(f)
2042 if len(meta_inf_files) < 3:
2043 return "Signature files missing from {0}".format(signed_apk)
2045 tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2046 unsigned = ZipFile(unsigned_apk, 'r')
2047 # only read the signature from the signed APK, everything else from unsigned
2048 with ZipFile(tmp_apk, 'w') as tmp:
2049 for filename in meta_inf_files:
2050 tmp.writestr(signed.getinfo(filename), signed.read(filename))
2051 for info in unsigned.infolist():
2052 if info.filename in meta_inf_files:
2053 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2055 if info.filename in tmp.namelist():
2056 return "duplicate filename found: " + info.filename
2057 tmp.writestr(info, unsigned.read(info.filename))
2061 verified = verify_apk_signature(tmp_apk)
2064 logging.info("...NOT verified - {0}".format(tmp_apk))
2065 return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2067 logging.info("...successfully verified")
2071 def verify_apk_signature(apk):
2072 """verify the signature on an APK
2074 Try to use apksigner whenever possible since jarsigner is very
2075 shitty: unsigned APKs pass as "verified"! So this has to turn on
2076 -strict then check for result 4.
2079 if set_command_in_config('apksigner'):
2080 return subprocess.call([config['apksigner'], 'verify', apk]) == 0
2082 logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2083 return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2086 apk_badchars = re.compile('''[/ :;'"]''')
2089 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2092 Returns None if the apk content is the same (apart from the signing key),
2093 otherwise a string describing what's different, or what went wrong when
2094 trying to do the comparison.
2100 absapk1 = os.path.abspath(apk1)
2101 absapk2 = os.path.abspath(apk2)
2103 if set_command_in_config('diffoscope'):
2104 logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2105 htmlfile = logfilename + '.diffoscope.html'
2106 textfile = logfilename + '.diffoscope.txt'
2107 if subprocess.call([config['diffoscope'],
2108 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2109 '--html', htmlfile, '--text', textfile,
2110 absapk1, absapk2]) != 0:
2111 return("Failed to unpack " + apk1)
2113 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2114 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2115 for d in [apk1dir, apk2dir]:
2116 if os.path.exists(d):
2119 os.mkdir(os.path.join(d, 'jar-xf'))
2121 if subprocess.call(['jar', 'xf',
2122 os.path.abspath(apk1)],
2123 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2124 return("Failed to unpack " + apk1)
2125 if subprocess.call(['jar', 'xf',
2126 os.path.abspath(apk2)],
2127 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2128 return("Failed to unpack " + apk2)
2130 if set_command_in_config('apktool'):
2131 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2133 return("Failed to unpack " + apk1)
2134 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2136 return("Failed to unpack " + apk2)
2138 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2139 lines = p.output.splitlines()
2140 if len(lines) != 1 or 'META-INF' not in lines[0]:
2141 meld = find_command('meld')
2142 if meld is not None:
2143 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2144 return("Unexpected diff output - " + p.output)
2146 # since everything verifies, delete the comparison to keep cruft down
2147 shutil.rmtree(apk1dir)
2148 shutil.rmtree(apk2dir)
2150 # If we get here, it seems like they're the same!
2154 def set_command_in_config(command):
2155 '''Try to find specified command in the path, if it hasn't been
2156 manually set in config.py. If found, it is added to the config
2157 dict. The return value says whether the command is available.
2160 if command in config:
2163 tmp = find_command(command)
2165 config[command] = tmp
2170 def find_command(command):
2171 '''find the full path of a command, or None if it can't be found in the PATH'''
2174 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2176 fpath, fname = os.path.split(command)
2181 for path in os.environ["PATH"].split(os.pathsep):
2182 path = path.strip('"')
2183 exe_file = os.path.join(path, command)
2184 if is_exe(exe_file):
2191 '''generate a random password for when generating keys'''
2192 h = hashlib.sha256()
2193 h.update(os.urandom(16)) # salt
2194 h.update(socket.getfqdn().encode('utf-8'))
2195 passwd = base64.b64encode(h.digest()).strip()
2196 return passwd.decode('utf-8')
2199 def genkeystore(localconfig):
2200 '''Generate a new key with random passwords and add it to new keystore'''
2201 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2202 keystoredir = os.path.dirname(localconfig['keystore'])
2203 if keystoredir is None or keystoredir == '':
2204 keystoredir = os.path.join(os.getcwd(), keystoredir)
2205 if not os.path.exists(keystoredir):
2206 os.makedirs(keystoredir, mode=0o700)
2208 write_password_file("keystorepass", localconfig['keystorepass'])
2209 write_password_file("keypass", localconfig['keypass'])
2210 p = FDroidPopen([config['keytool'], '-genkey',
2211 '-keystore', localconfig['keystore'],
2212 '-alias', localconfig['repo_keyalias'],
2213 '-keyalg', 'RSA', '-keysize', '4096',
2214 '-sigalg', 'SHA256withRSA',
2215 '-validity', '10000',
2216 '-storepass:file', config['keystorepassfile'],
2217 '-keypass:file', config['keypassfile'],
2218 '-dname', localconfig['keydname']])
2219 # TODO keypass should be sent via stdin
2220 if p.returncode != 0:
2221 raise BuildException("Failed to generate key", p.output)
2222 os.chmod(localconfig['keystore'], 0o0600)
2223 # now show the lovely key that was just generated
2224 p = FDroidPopen([config['keytool'], '-list', '-v',
2225 '-keystore', localconfig['keystore'],
2226 '-alias', localconfig['repo_keyalias'],
2227 '-storepass:file', config['keystorepassfile']])
2228 logging.info(p.output.strip() + '\n\n')
2231 def write_to_config(thisconfig, key, value=None):
2232 '''write a key/value to the local config.py'''
2234 origkey = key + '_orig'
2235 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2236 with open('config.py', 'r', encoding='utf8') as f:
2238 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2239 repl = '\n' + key + ' = "' + value + '"'
2240 data = re.sub(pattern, repl, data)
2241 # if this key is not in the file, append it
2242 if not re.match('\s*' + key + '\s*=\s*"', data):
2244 # make sure the file ends with a carraige return
2245 if not re.match('\n$', data):
2247 with open('config.py', 'w', encoding='utf8') as f:
2251 def parse_xml(path):
2252 return XMLElementTree.parse(path).getroot()
2255 def string_is_integer(string):
2263 def get_per_app_repos():
2264 '''per-app repos are dirs named with the packageName of a single app'''
2266 # Android packageNames are Java packages, they may contain uppercase or
2267 # lowercase letters ('A' through 'Z'), numbers, and underscores
2268 # ('_'). However, individual package name parts may only start with
2269 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2270 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2273 for root, dirs, files in os.walk(os.getcwd()):
2275 print('checking', root, 'for', d)
2276 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2277 # standard parts of an fdroid repo, so never packageNames
2280 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2286 def is_repo_file(filename):
2287 '''Whether the file in a repo is a build product to be delivered to users'''
2288 return os.path.isfile(filename) \
2289 and not filename.endswith('.asc') \
2290 and not filename.endswith('.sig') \
2291 and os.path.basename(filename) not in [
2293 'index_unsigned.jar',