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 distutils.version import LooseVersion
40 from queue import Queue
41 from zipfile import ZipFile
43 import fdroidserver.metadata
44 from .asynchronousfilereader import AsynchronousFileReader
47 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
56 'sdk_path': "$ANDROID_HOME",
61 'r12b': "$ANDROID_NDK",
65 'build_tools': "25.0.2",
66 'force_build_tools': False,
71 'accepted_formats': ['txt', 'yml'],
72 'sync_from_local_copy_dir': False,
73 'per_app_repos': False,
74 'make_current_version_link': True,
75 'current_version_name_source': 'Name',
76 'update_stats': False,
80 'stats_to_carbon': False,
82 'build_server_always': False,
83 'keystore': 'keystore.jks',
84 'smartcardoptions': [],
90 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
91 'repo_name': "My First FDroid Repo Demo",
92 'repo_icon': "fdroid-icon.png",
93 'repo_description': '''
94 This is a repository of apps to be used with FDroid. Applications in this
95 repository are either official binaries built by the original application
96 developers, or are binaries built from source by the admin of f-droid.org
97 using the tools on https://gitlab.com/u/fdroid.
103 def setup_global_opts(parser):
104 parser.add_argument("-v", "--verbose", action="store_true", default=False,
105 help="Spew out even more information than normal")
106 parser.add_argument("-q", "--quiet", action="store_true", default=False,
107 help="Restrict output to warnings and errors")
110 def fill_config_defaults(thisconfig):
111 for k, v in default_config.items():
112 if k not in thisconfig:
115 # Expand paths (~users and $vars)
116 def expand_path(path):
120 path = os.path.expanduser(path)
121 path = os.path.expandvars(path)
126 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
131 thisconfig[k + '_orig'] = v
133 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
134 if thisconfig['java_paths'] is None:
135 thisconfig['java_paths'] = dict()
137 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
138 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
139 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
140 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
141 if os.getenv('JAVA_HOME') is not None:
142 pathlist.append(os.getenv('JAVA_HOME'))
143 if os.getenv('PROGRAMFILES') is not None:
144 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
145 for d in sorted(pathlist):
146 if os.path.islink(d):
148 j = os.path.basename(d)
149 # the last one found will be the canonical one, so order appropriately
151 r'^1\.([6-9])\.0\.jdk$', # OSX
152 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
153 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
154 r'^jdk([6-9])-openjdk$', # Arch
155 r'^java-([6-9])-openjdk$', # Arch
156 r'^java-([6-9])-jdk$', # Arch (oracle)
157 r'^java-1\.([6-9])\.0-.*$', # RedHat
158 r'^java-([6-9])-oracle$', # Debian WebUpd8
159 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
160 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
162 m = re.match(regex, j)
165 for p in [d, os.path.join(d, 'Contents', 'Home')]:
166 if os.path.exists(os.path.join(p, 'bin', 'javac')):
167 thisconfig['java_paths'][m.group(1)] = p
169 for java_version in ('7', '8', '9'):
170 if java_version not in thisconfig['java_paths']:
172 java_home = thisconfig['java_paths'][java_version]
173 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
174 if os.path.exists(jarsigner):
175 thisconfig['jarsigner'] = jarsigner
176 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
177 break # Java7 is preferred, so quit if found
179 for k in ['ndk_paths', 'java_paths']:
185 thisconfig[k][k2] = exp
186 thisconfig[k][k2 + '_orig'] = v
189 def regsub_file(pattern, repl, path):
190 with open(path, 'rb') as f:
192 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
193 with open(path, 'wb') as f:
197 def read_config(opts, config_file='config.py'):
198 """Read the repository config
200 The config is read from config_file, which is in the current
201 directory when any of the repo management commands are used. If
202 there is a local metadata file in the git repo, then config.py is
203 not required, just use defaults.
206 global config, options
208 if config is not None:
215 if os.path.isfile(config_file):
216 logging.debug("Reading %s" % config_file)
217 with io.open(config_file, "rb") as f:
218 code = compile(f.read(), config_file, 'exec')
219 exec(code, None, config)
220 elif len(get_local_metadata_files()) == 0:
221 logging.critical("Missing config file - is this a repo directory?")
224 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
226 if not type(config[k]) in (str, list, tuple):
227 logging.warn('"' + k + '" will be in random order!'
228 + ' Use () or [] brackets if order is important!')
230 # smartcardoptions must be a list since its command line args for Popen
231 if 'smartcardoptions' in config:
232 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
233 elif 'keystore' in config and config['keystore'] == 'NONE':
234 # keystore='NONE' means use smartcard, these are required defaults
235 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
236 'SunPKCS11-OpenSC', '-providerClass',
237 'sun.security.pkcs11.SunPKCS11',
238 '-providerArg', 'opensc-fdroid.cfg']
240 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
241 st = os.stat(config_file)
242 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
243 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
245 fill_config_defaults(config)
247 for k in ["keystorepass", "keypass"]:
249 write_password_file(k)
251 for k in ["repo_description", "archive_description"]:
253 config[k] = clean_description(config[k])
255 if 'serverwebroot' in config:
256 if isinstance(config['serverwebroot'], str):
257 roots = [config['serverwebroot']]
258 elif all(isinstance(item, str) for item in config['serverwebroot']):
259 roots = config['serverwebroot']
261 raise TypeError('only accepts strings, lists, and tuples')
263 for rootstr in roots:
264 # since this is used with rsync, where trailing slashes have
265 # meaning, ensure there is always a trailing slash
266 if rootstr[-1] != '/':
268 rootlist.append(rootstr.replace('//', '/'))
269 config['serverwebroot'] = rootlist
271 if 'servergitmirrors' in config:
272 if isinstance(config['servergitmirrors'], str):
273 roots = [config['servergitmirrors']]
274 elif all(isinstance(item, str) for item in config['servergitmirrors']):
275 roots = config['servergitmirrors']
277 raise TypeError('only accepts strings, lists, and tuples')
278 config['servergitmirrors'] = roots
283 def find_sdk_tools_cmd(cmd):
284 '''find a working path to a tool from the Android SDK'''
287 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
288 # try to find a working path to this command, in all the recent possible paths
289 if 'build_tools' in config:
290 build_tools = os.path.join(config['sdk_path'], 'build-tools')
291 # if 'build_tools' was manually set and exists, check only that one
292 configed_build_tools = os.path.join(build_tools, config['build_tools'])
293 if os.path.exists(configed_build_tools):
294 tooldirs.append(configed_build_tools)
296 # no configed version, so hunt known paths for it
297 for f in sorted(os.listdir(build_tools), reverse=True):
298 if os.path.isdir(os.path.join(build_tools, f)):
299 tooldirs.append(os.path.join(build_tools, f))
300 tooldirs.append(build_tools)
301 sdk_tools = os.path.join(config['sdk_path'], 'tools')
302 if os.path.exists(sdk_tools):
303 tooldirs.append(sdk_tools)
304 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
305 if os.path.exists(sdk_platform_tools):
306 tooldirs.append(sdk_platform_tools)
307 tooldirs.append('/usr/bin')
309 path = os.path.join(d, cmd)
310 if os.path.isfile(path):
312 test_aapt_version(path)
314 # did not find the command, exit with error message
315 ensure_build_tools_exists(config)
318 def test_aapt_version(aapt):
319 '''Check whether the version of aapt is new enough'''
320 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
321 if output is None or output == '':
322 logging.error(aapt + ' failed to execute!')
324 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
329 # the Debian package has the version string like "v0.2-23.0.2"
330 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
331 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
333 logging.warning('Unknown version of aapt, might cause problems: ' + output)
336 def test_sdk_exists(thisconfig):
337 if 'sdk_path' not in thisconfig:
338 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
339 test_aapt_version(thisconfig['aapt'])
342 logging.error("'sdk_path' not set in config.py!")
344 if thisconfig['sdk_path'] == default_config['sdk_path']:
345 logging.error('No Android SDK found!')
346 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
347 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
349 if not os.path.exists(thisconfig['sdk_path']):
350 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
352 if not os.path.isdir(thisconfig['sdk_path']):
353 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
355 for d in ['build-tools', 'platform-tools', 'tools']:
356 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
357 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
358 thisconfig['sdk_path'], d))
363 def ensure_build_tools_exists(thisconfig):
364 if not test_sdk_exists(thisconfig):
366 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
367 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
368 if not os.path.isdir(versioned_build_tools):
369 logging.critical('Android Build Tools path "'
370 + versioned_build_tools + '" does not exist!')
374 def write_password_file(pwtype, password=None):
376 writes out passwords to a protected file instead of passing passwords as
377 command line argments
379 filename = '.fdroid.' + pwtype + '.txt'
380 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
382 os.write(fd, config[pwtype].encode('utf-8'))
384 os.write(fd, password.encode('utf-8'))
386 config[pwtype + 'file'] = filename
389 def get_local_metadata_files():
390 '''get any metadata files local to an app's source repo
392 This tries to ignore anything that does not count as app metdata,
393 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
396 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
399 def read_pkg_args(args, allow_vercodes=False):
401 Given the arguments in the form of multiple appid:[vc] strings, this returns
402 a dictionary with the set of vercodes specified for each package.
410 if allow_vercodes and ':' in p:
411 package, vercode = p.split(':')
413 package, vercode = p, None
414 if package not in vercodes:
415 vercodes[package] = [vercode] if vercode else []
417 elif vercode and vercode not in vercodes[package]:
418 vercodes[package] += [vercode] if vercode else []
423 def read_app_args(args, allapps, allow_vercodes=False):
425 On top of what read_pkg_args does, this returns the whole app metadata, but
426 limiting the builds list to the builds matching the vercodes specified.
429 vercodes = read_pkg_args(args, allow_vercodes)
435 for appid, app in allapps.items():
436 if appid in vercodes:
439 if len(apps) != len(vercodes):
442 logging.critical("No such package: %s" % p)
443 raise FDroidException("Found invalid app ids in arguments")
445 raise FDroidException("No packages specified")
448 for appid, app in apps.items():
452 app.builds = [b for b in app.builds if b.versionCode in vc]
453 if len(app.builds) != len(vercodes[appid]):
455 allvcs = [b.versionCode for b in app.builds]
456 for v in vercodes[appid]:
458 logging.critical("No such vercode %s for app %s" % (v, appid))
461 raise FDroidException("Found invalid vercodes for some apps")
466 def get_extension(filename):
467 base, ext = os.path.splitext(filename)
470 return base, ext.lower()[1:]
473 def has_extension(filename, ext):
474 _, f_ext = get_extension(filename)
478 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
481 def clean_description(description):
482 'Remove unneeded newlines and spaces from a block of description text'
484 # this is split up by paragraph to make removing the newlines easier
485 for paragraph in re.split(r'\n\n', description):
486 paragraph = re.sub('\r', '', paragraph)
487 paragraph = re.sub('\n', ' ', paragraph)
488 paragraph = re.sub(' {2,}', ' ', paragraph)
489 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
490 returnstring += paragraph + '\n\n'
491 return returnstring.rstrip('\n')
494 def publishednameinfo(filename):
495 filename = os.path.basename(filename)
496 m = publish_name_regex.match(filename)
498 result = (m.group(1), m.group(2))
499 except AttributeError:
500 raise FDroidException("Invalid name for published file: %s" % filename)
504 def get_release_filename(app, build):
506 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
508 return "%s_%s.apk" % (app.id, build.versionCode)
511 def getsrcname(app, build):
512 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
524 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
527 def get_build_dir(app):
528 '''get the dir that this app will be built in'''
530 if app.RepoType == 'srclib':
531 return os.path.join('build', 'srclib', app.Repo)
533 return os.path.join('build', app.id)
537 '''checkout code from VCS and return instance of vcs and the build dir'''
538 build_dir = get_build_dir(app)
540 # Set up vcs interface and make sure we have the latest code...
541 logging.debug("Getting {0} vcs interface for {1}"
542 .format(app.RepoType, app.Repo))
543 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
547 vcs = getvcs(app.RepoType, remote, build_dir)
549 return vcs, build_dir
552 def getvcs(vcstype, remote, local):
554 return vcs_git(remote, local)
555 if vcstype == 'git-svn':
556 return vcs_gitsvn(remote, local)
558 return vcs_hg(remote, local)
560 return vcs_bzr(remote, local)
561 if vcstype == 'srclib':
562 if local != os.path.join('build', 'srclib', remote):
563 raise VCSException("Error: srclib paths are hard-coded!")
564 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
566 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
567 raise VCSException("Invalid vcs type " + vcstype)
570 def getsrclibvcs(name):
571 if name not in fdroidserver.metadata.srclibs:
572 raise VCSException("Missing srclib " + name)
573 return fdroidserver.metadata.srclibs[name]['Repo Type']
578 def __init__(self, remote, local):
580 # svn, git-svn and bzr may require auth
582 if self.repotype() in ('git-svn', 'bzr'):
584 if self.repotype == 'git-svn':
585 raise VCSException("Authentication is not supported for git-svn")
586 self.username, remote = remote.split('@')
587 if ':' not in self.username:
588 raise VCSException("Password required with username")
589 self.username, self.password = self.username.split(':')
593 self.clone_failed = False
594 self.refreshed = False
600 # Take the local repository to a clean version of the given revision, which
601 # is specificed in the VCS's native format. Beforehand, the repository can
602 # be dirty, or even non-existent. If the repository does already exist
603 # locally, it will be updated from the origin, but only once in the
604 # lifetime of the vcs object.
605 # None is acceptable for 'rev' if you know you are cloning a clean copy of
606 # the repo - otherwise it must specify a valid revision.
607 def gotorevision(self, rev, refresh=True):
609 if self.clone_failed:
610 raise VCSException("Downloading the repository already failed once, not trying again.")
612 # The .fdroidvcs-id file for a repo tells us what VCS type
613 # and remote that directory was created from, allowing us to drop it
614 # automatically if either of those things changes.
615 fdpath = os.path.join(self.local, '..',
616 '.fdroidvcs-' + os.path.basename(self.local))
617 fdpath = os.path.normpath(fdpath)
618 cdata = self.repotype() + ' ' + self.remote
621 if os.path.exists(self.local):
622 if os.path.exists(fdpath):
623 with open(fdpath, 'r') as f:
624 fsdata = f.read().strip()
629 logging.info("Repository details for %s changed - deleting" % (
633 logging.info("Repository details for %s missing - deleting" % (
636 shutil.rmtree(self.local)
640 self.refreshed = True
643 self.gotorevisionx(rev)
644 except FDroidException as e:
647 # If necessary, write the .fdroidvcs file.
648 if writeback and not self.clone_failed:
649 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
650 with open(fdpath, 'w+') as f:
656 # Derived classes need to implement this. It's called once basic checking
657 # has been performend.
658 def gotorevisionx(self, rev):
659 raise VCSException("This VCS type doesn't define gotorevisionx")
661 # Initialise and update submodules
662 def initsubmodules(self):
663 raise VCSException('Submodules not supported for this vcs type')
665 # Get a list of all known tags
667 if not self._gettags:
668 raise VCSException('gettags not supported for this vcs type')
670 for tag in self._gettags():
671 if re.match('[-A-Za-z0-9_. /]+$', tag):
675 # Get a list of all the known tags, sorted from newest to oldest
676 def latesttags(self):
677 raise VCSException('latesttags not supported for this vcs type')
679 # Get current commit reference (hash, revision, etc)
681 raise VCSException('getref not supported for this vcs type')
683 # Returns the srclib (name, path) used in setting up the current
694 # If the local directory exists, but is somehow not a git repository, git
695 # will traverse up the directory tree until it finds one that is (i.e.
696 # fdroidserver) and then we'll proceed to destroy it! This is called as
699 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
700 result = p.output.rstrip()
701 if not result.endswith(self.local):
702 raise VCSException('Repository mismatch')
704 def gotorevisionx(self, rev):
705 if not os.path.exists(self.local):
707 p = FDroidPopen(['git', 'clone', self.remote, self.local])
708 if p.returncode != 0:
709 self.clone_failed = True
710 raise VCSException("Git clone failed", p.output)
714 # Discard any working tree changes
715 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
716 'git', 'reset', '--hard'], cwd=self.local, output=False)
717 if p.returncode != 0:
718 raise VCSException("Git reset failed", p.output)
719 # Remove untracked files now, in case they're tracked in the target
720 # revision (it happens!)
721 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
722 'git', 'clean', '-dffx'], cwd=self.local, output=False)
723 if p.returncode != 0:
724 raise VCSException("Git clean failed", p.output)
725 if not self.refreshed:
726 # Get latest commits and tags from remote
727 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
728 if p.returncode != 0:
729 raise VCSException("Git fetch failed", p.output)
730 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
731 if p.returncode != 0:
732 raise VCSException("Git fetch failed", p.output)
733 # Recreate origin/HEAD as git clone would do it, in case it disappeared
734 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
735 if p.returncode != 0:
736 lines = p.output.splitlines()
737 if 'Multiple remote HEAD branches' not in lines[0]:
738 raise VCSException("Git remote set-head failed", p.output)
739 branch = lines[1].split(' ')[-1]
740 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
741 if p2.returncode != 0:
742 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
743 self.refreshed = True
744 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
745 # a github repo. Most of the time this is the same as origin/master.
746 rev = rev or 'origin/HEAD'
747 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
748 if p.returncode != 0:
749 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
750 # Get rid of any uncontrolled files left behind
751 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
752 if p.returncode != 0:
753 raise VCSException("Git clean failed", p.output)
755 def initsubmodules(self):
757 submfile = os.path.join(self.local, '.gitmodules')
758 if not os.path.isfile(submfile):
759 raise VCSException("No git submodules available")
761 # fix submodules not accessible without an account and public key auth
762 with open(submfile, 'r') as f:
763 lines = f.readlines()
764 with open(submfile, 'w') as f:
766 if 'git@github.com' in line:
767 line = line.replace('git@github.com:', 'https://github.com/')
768 if 'git@gitlab.com' in line:
769 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
772 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
773 if p.returncode != 0:
774 raise VCSException("Git submodule sync failed", p.output)
775 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
776 if p.returncode != 0:
777 raise VCSException("Git submodule update failed", p.output)
781 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
782 return p.output.splitlines()
784 tag_format = re.compile(r'tag: ([^),]*)')
786 def latesttags(self):
788 p = FDroidPopen(['git', 'log', '--tags',
789 '--simplify-by-decoration', '--pretty=format:%d'],
790 cwd=self.local, output=False)
792 for line in p.output.splitlines():
793 for tag in self.tag_format.findall(line):
798 class vcs_gitsvn(vcs):
803 # If the local directory exists, but is somehow not a git repository, git
804 # will traverse up the directory tree until it finds one that is (i.e.
805 # fdroidserver) and then we'll proceed to destory it! This is called as
808 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
809 result = p.output.rstrip()
810 if not result.endswith(self.local):
811 raise VCSException('Repository mismatch')
813 def gotorevisionx(self, rev):
814 if not os.path.exists(self.local):
816 gitsvn_args = ['git', 'svn', 'clone']
817 if ';' in self.remote:
818 remote_split = self.remote.split(';')
819 for i in remote_split[1:]:
820 if i.startswith('trunk='):
821 gitsvn_args.extend(['-T', i[6:]])
822 elif i.startswith('tags='):
823 gitsvn_args.extend(['-t', i[5:]])
824 elif i.startswith('branches='):
825 gitsvn_args.extend(['-b', i[9:]])
826 gitsvn_args.extend([remote_split[0], self.local])
827 p = FDroidPopen(gitsvn_args, output=False)
828 if p.returncode != 0:
829 self.clone_failed = True
830 raise VCSException("Git svn clone failed", p.output)
832 gitsvn_args.extend([self.remote, self.local])
833 p = FDroidPopen(gitsvn_args, output=False)
834 if p.returncode != 0:
835 self.clone_failed = True
836 raise VCSException("Git svn clone failed", p.output)
840 # Discard any working tree changes
841 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
842 if p.returncode != 0:
843 raise VCSException("Git reset failed", p.output)
844 # Remove untracked files now, in case they're tracked in the target
845 # revision (it happens!)
846 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
847 if p.returncode != 0:
848 raise VCSException("Git clean failed", p.output)
849 if not self.refreshed:
850 # Get new commits, branches and tags from repo
851 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
852 if p.returncode != 0:
853 raise VCSException("Git svn fetch failed")
854 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
855 if p.returncode != 0:
856 raise VCSException("Git svn rebase failed", p.output)
857 self.refreshed = True
859 rev = rev or 'master'
861 nospaces_rev = rev.replace(' ', '%20')
862 # Try finding a svn tag
863 for treeish in ['origin/', '']:
864 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
865 if p.returncode == 0:
867 if p.returncode != 0:
868 # No tag found, normal svn rev translation
869 # Translate svn rev into git format
870 rev_split = rev.split('/')
873 for treeish in ['origin/', '']:
874 if len(rev_split) > 1:
875 treeish += rev_split[0]
876 svn_rev = rev_split[1]
879 # if no branch is specified, then assume trunk (i.e. 'master' branch):
883 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
885 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
886 git_rev = p.output.rstrip()
888 if p.returncode == 0 and git_rev:
891 if p.returncode != 0 or not git_rev:
892 # Try a plain git checkout as a last resort
893 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
894 if p.returncode != 0:
895 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
897 # Check out the git rev equivalent to the svn rev
898 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
899 if p.returncode != 0:
900 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
902 # Get rid of any uncontrolled files left behind
903 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
904 if p.returncode != 0:
905 raise VCSException("Git clean failed", p.output)
909 for treeish in ['origin/', '']:
910 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
916 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
917 if p.returncode != 0:
919 return p.output.strip()
927 def gotorevisionx(self, rev):
928 if not os.path.exists(self.local):
929 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
930 if p.returncode != 0:
931 self.clone_failed = True
932 raise VCSException("Hg clone failed", p.output)
934 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
935 if p.returncode != 0:
936 raise VCSException("Hg status failed", p.output)
937 for line in p.output.splitlines():
938 if not line.startswith('? '):
939 raise VCSException("Unexpected output from hg status -uS: " + line)
940 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
941 if not self.refreshed:
942 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
943 if p.returncode != 0:
944 raise VCSException("Hg pull failed", p.output)
945 self.refreshed = True
947 rev = rev or 'default'
950 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
951 if p.returncode != 0:
952 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
953 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
954 # Also delete untracked files, we have to enable purge extension for that:
955 if "'purge' is provided by the following extension" in p.output:
956 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
957 myfile.write("\n[extensions]\nhgext.purge=\n")
958 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
959 if p.returncode != 0:
960 raise VCSException("HG purge failed", p.output)
961 elif p.returncode != 0:
962 raise VCSException("HG purge failed", p.output)
965 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
966 return p.output.splitlines()[1:]
974 def gotorevisionx(self, rev):
975 if not os.path.exists(self.local):
976 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
977 if p.returncode != 0:
978 self.clone_failed = True
979 raise VCSException("Bzr branch failed", p.output)
981 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
982 if p.returncode != 0:
983 raise VCSException("Bzr revert failed", p.output)
984 if not self.refreshed:
985 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
986 if p.returncode != 0:
987 raise VCSException("Bzr update failed", p.output)
988 self.refreshed = True
990 revargs = list(['-r', rev] if rev else [])
991 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
992 if p.returncode != 0:
993 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
996 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
997 return [tag.split(' ')[0].strip() for tag in
998 p.output.splitlines()]
1001 def unescape_string(string):
1004 if string[0] == '"' and string[-1] == '"':
1007 return string.replace("\\'", "'")
1010 def retrieve_string(app_dir, string, xmlfiles=None):
1012 if not string.startswith('@string/'):
1013 return unescape_string(string)
1015 if xmlfiles is None:
1018 os.path.join(app_dir, 'res'),
1019 os.path.join(app_dir, 'src', 'main', 'res'),
1021 for r, d, f in os.walk(res_dir):
1022 if os.path.basename(r) == 'values':
1023 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1025 name = string[len('@string/'):]
1027 def element_content(element):
1028 if element.text is None:
1030 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1031 return s.decode('utf-8').strip()
1033 for path in xmlfiles:
1034 if not os.path.isfile(path):
1036 xml = parse_xml(path)
1037 element = xml.find('string[@name="' + name + '"]')
1038 if element is not None:
1039 content = element_content(element)
1040 return retrieve_string(app_dir, content, xmlfiles)
1045 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1046 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1049 def manifest_paths(app_dir, flavours):
1050 '''Return list of existing files that will be used to find the highest vercode'''
1052 possible_manifests = \
1053 [os.path.join(app_dir, 'AndroidManifest.xml'),
1054 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1055 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1056 os.path.join(app_dir, 'build.gradle')]
1058 for flavour in flavours:
1059 if flavour == 'yes':
1061 possible_manifests.append(
1062 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1064 return [path for path in possible_manifests if os.path.isfile(path)]
1067 def fetch_real_name(app_dir, flavours):
1068 '''Retrieve the package name. Returns the name, or None if not found.'''
1069 for path in manifest_paths(app_dir, flavours):
1070 if not has_extension(path, 'xml') or not os.path.isfile(path):
1072 logging.debug("fetch_real_name: Checking manifest at " + path)
1073 xml = parse_xml(path)
1074 app = xml.find('application')
1077 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1079 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1080 result = retrieve_string_singleline(app_dir, label)
1082 result = result.strip()
1087 def get_library_references(root_dir):
1089 proppath = os.path.join(root_dir, 'project.properties')
1090 if not os.path.isfile(proppath):
1092 with open(proppath, 'r', encoding='iso-8859-1') as f:
1094 if not line.startswith('android.library.reference.'):
1096 path = line.split('=')[1].strip()
1097 relpath = os.path.join(root_dir, path)
1098 if not os.path.isdir(relpath):
1100 logging.debug("Found subproject at %s" % path)
1101 libraries.append(path)
1105 def ant_subprojects(root_dir):
1106 subprojects = get_library_references(root_dir)
1107 for subpath in subprojects:
1108 subrelpath = os.path.join(root_dir, subpath)
1109 for p in get_library_references(subrelpath):
1110 relp = os.path.normpath(os.path.join(subpath, p))
1111 if relp not in subprojects:
1112 subprojects.insert(0, relp)
1116 def remove_debuggable_flags(root_dir):
1117 # Remove forced debuggable flags
1118 logging.debug("Removing debuggable flags from %s" % root_dir)
1119 for root, dirs, files in os.walk(root_dir):
1120 if 'AndroidManifest.xml' in files:
1121 regsub_file(r'android:debuggable="[^"]*"',
1123 os.path.join(root, 'AndroidManifest.xml'))
1126 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1127 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1128 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1131 def app_matches_packagename(app, package):
1134 appid = app.UpdateCheckName or app.id
1135 if appid is None or appid == "Ignore":
1137 return appid == package
1140 def parse_androidmanifests(paths, app):
1142 Extract some information from the AndroidManifest.xml at the given path.
1143 Returns (version, vercode, package), any or all of which might be None.
1144 All values returned are strings.
1147 ignoreversions = app.UpdateCheckIgnore
1148 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1151 return (None, None, None)
1159 if not os.path.isfile(path):
1162 logging.debug("Parsing manifest at {0}".format(path))
1167 if has_extension(path, 'gradle'):
1168 with open(path, 'r') as f:
1170 if gradle_comment.match(line):
1172 # Grab first occurence of each to avoid running into
1173 # alternative flavours and builds.
1175 matches = psearch_g(line)
1177 s = matches.group(2)
1178 if app_matches_packagename(app, s):
1181 matches = vnsearch_g(line)
1183 version = matches.group(2)
1185 matches = vcsearch_g(line)
1187 vercode = matches.group(1)
1190 xml = parse_xml(path)
1191 if "package" in xml.attrib:
1192 s = xml.attrib["package"]
1193 if app_matches_packagename(app, s):
1195 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1196 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1197 base_dir = os.path.dirname(path)
1198 version = retrieve_string_singleline(base_dir, version)
1199 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1200 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1201 if string_is_integer(a):
1204 logging.warning("Problem with xml at {0}".format(path))
1206 # Remember package name, may be defined separately from version+vercode
1208 package = max_package
1210 logging.debug("..got package={0}, version={1}, vercode={2}"
1211 .format(package, version, vercode))
1213 # Always grab the package name and version name in case they are not
1214 # together with the highest version code
1215 if max_package is None and package is not None:
1216 max_package = package
1217 if max_version is None and version is not None:
1218 max_version = version
1220 if vercode is not None \
1221 and (max_vercode is None or vercode > max_vercode):
1222 if not ignoresearch or not ignoresearch(version):
1223 if version is not None:
1224 max_version = version
1225 if vercode is not None:
1226 max_vercode = vercode
1227 if package is not None:
1228 max_package = package
1230 max_version = "Ignore"
1232 if max_version is None:
1233 max_version = "Unknown"
1235 if max_package and not is_valid_package_name(max_package):
1236 raise FDroidException("Invalid package name {0}".format(max_package))
1238 return (max_version, max_vercode, max_package)
1241 def is_valid_package_name(name):
1242 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1245 class FDroidException(Exception):
1247 def __init__(self, value, detail=None):
1249 self.detail = detail
1251 def shortened_detail(self):
1252 if len(self.detail) < 16000:
1254 return '[...]\n' + self.detail[-16000:]
1256 def get_wikitext(self):
1257 ret = repr(self.value) + "\n"
1260 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1266 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1270 class VCSException(FDroidException):
1274 class BuildException(FDroidException):
1278 # Get the specified source library.
1279 # Returns the path to it. Normally this is the path to be used when referencing
1280 # it, which may be a subdirectory of the actual project. If you want the base
1281 # directory of the project, pass 'basepath=True'.
1282 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1283 raw=False, prepare=True, preponly=False, refresh=True,
1292 name, ref = spec.split('@')
1294 number, name = name.split(':', 1)
1296 name, subdir = name.split('/', 1)
1298 if name not in fdroidserver.metadata.srclibs:
1299 raise VCSException('srclib ' + name + ' not found.')
1301 srclib = fdroidserver.metadata.srclibs[name]
1303 sdir = os.path.join(srclib_dir, name)
1306 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1307 vcs.srclib = (name, number, sdir)
1309 vcs.gotorevision(ref, refresh)
1316 libdir = os.path.join(sdir, subdir)
1317 elif srclib["Subdir"]:
1318 for subdir in srclib["Subdir"]:
1319 libdir_candidate = os.path.join(sdir, subdir)
1320 if os.path.exists(libdir_candidate):
1321 libdir = libdir_candidate
1327 remove_signing_keys(sdir)
1328 remove_debuggable_flags(sdir)
1332 if srclib["Prepare"]:
1333 cmd = replace_config_vars(srclib["Prepare"], build)
1335 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1336 if p.returncode != 0:
1337 raise BuildException("Error running prepare command for srclib %s"
1343 return (name, number, libdir)
1346 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1349 # Prepare the source code for a particular build
1350 # 'vcs' - the appropriate vcs object for the application
1351 # 'app' - the application details from the metadata
1352 # 'build' - the build details from the metadata
1353 # 'build_dir' - the path to the build directory, usually
1355 # 'srclib_dir' - the path to the source libraries directory, usually
1357 # 'extlib_dir' - the path to the external libraries directory, usually
1359 # Returns the (root, srclibpaths) where:
1360 # 'root' is the root directory, which may be the same as 'build_dir' or may
1361 # be a subdirectory of it.
1362 # 'srclibpaths' is information on the srclibs being used
1363 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1365 # Optionally, the actual app source can be in a subdirectory
1367 root_dir = os.path.join(build_dir, build.subdir)
1369 root_dir = build_dir
1371 # Get a working copy of the right revision
1372 logging.info("Getting source for revision " + build.commit)
1373 vcs.gotorevision(build.commit, refresh)
1375 # Initialise submodules if required
1376 if build.submodules:
1377 logging.info("Initialising submodules")
1378 vcs.initsubmodules()
1380 # Check that a subdir (if we're using one) exists. This has to happen
1381 # after the checkout, since it might not exist elsewhere
1382 if not os.path.exists(root_dir):
1383 raise BuildException('Missing subdir ' + root_dir)
1385 # Run an init command if one is required
1387 cmd = replace_config_vars(build.init, build)
1388 logging.info("Running 'init' commands in %s" % root_dir)
1390 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1391 if p.returncode != 0:
1392 raise BuildException("Error running init command for %s:%s" %
1393 (app.id, build.versionName), p.output)
1395 # Apply patches if any
1397 logging.info("Applying patches")
1398 for patch in build.patch:
1399 patch = patch.strip()
1400 logging.info("Applying " + patch)
1401 patch_path = os.path.join('metadata', app.id, patch)
1402 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1403 if p.returncode != 0:
1404 raise BuildException("Failed to apply patch %s" % patch_path)
1406 # Get required source libraries
1409 logging.info("Collecting source libraries")
1410 for lib in build.srclibs:
1411 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1412 refresh=refresh, build=build))
1414 for name, number, libpath in srclibpaths:
1415 place_srclib(root_dir, int(number) if number else None, libpath)
1417 basesrclib = vcs.getsrclib()
1418 # If one was used for the main source, add that too.
1420 srclibpaths.append(basesrclib)
1422 # Update the local.properties file
1423 localprops = [os.path.join(build_dir, 'local.properties')]
1425 parts = build.subdir.split(os.sep)
1428 cur = os.path.join(cur, d)
1429 localprops += [os.path.join(cur, 'local.properties')]
1430 for path in localprops:
1432 if os.path.isfile(path):
1433 logging.info("Updating local.properties file at %s" % path)
1434 with open(path, 'r', encoding='iso-8859-1') as f:
1438 logging.info("Creating local.properties file at %s" % path)
1439 # Fix old-fashioned 'sdk-location' by copying
1440 # from sdk.dir, if necessary
1442 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1443 re.S | re.M).group(1)
1444 props += "sdk-location=%s\n" % sdkloc
1446 props += "sdk.dir=%s\n" % config['sdk_path']
1447 props += "sdk-location=%s\n" % config['sdk_path']
1448 ndk_path = build.ndk_path()
1449 # if for any reason the path isn't valid or the directory
1450 # doesn't exist, some versions of Gradle will error with a
1451 # cryptic message (even if the NDK is not even necessary).
1452 # https://gitlab.com/fdroid/fdroidserver/issues/171
1453 if ndk_path and os.path.exists(ndk_path):
1455 props += "ndk.dir=%s\n" % ndk_path
1456 props += "ndk-location=%s\n" % ndk_path
1457 # Add java.encoding if necessary
1459 props += "java.encoding=%s\n" % build.encoding
1460 with open(path, 'w', encoding='iso-8859-1') as f:
1464 if build.build_method() == 'gradle':
1465 flavours = build.gradle
1468 n = build.target.split('-')[1]
1469 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1470 r'compileSdkVersion %s' % n,
1471 os.path.join(root_dir, 'build.gradle'))
1473 # Remove forced debuggable flags
1474 remove_debuggable_flags(root_dir)
1476 # Insert version code and number into the manifest if necessary
1477 if build.forceversion:
1478 logging.info("Changing the version name")
1479 for path in manifest_paths(root_dir, flavours):
1480 if not os.path.isfile(path):
1482 if has_extension(path, 'xml'):
1483 regsub_file(r'android:versionName="[^"]*"',
1484 r'android:versionName="%s"' % build.versionName,
1486 elif has_extension(path, 'gradle'):
1487 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1488 r"""\1versionName '%s'""" % build.versionName,
1491 if build.forcevercode:
1492 logging.info("Changing the version code")
1493 for path in manifest_paths(root_dir, flavours):
1494 if not os.path.isfile(path):
1496 if has_extension(path, 'xml'):
1497 regsub_file(r'android:versionCode="[^"]*"',
1498 r'android:versionCode="%s"' % build.versionCode,
1500 elif has_extension(path, 'gradle'):
1501 regsub_file(r'versionCode[ =]+[0-9]+',
1502 r'versionCode %s' % build.versionCode,
1505 # Delete unwanted files
1507 logging.info("Removing specified files")
1508 for part in getpaths(build_dir, build.rm):
1509 dest = os.path.join(build_dir, part)
1510 logging.info("Removing {0}".format(part))
1511 if os.path.lexists(dest):
1512 if os.path.islink(dest):
1513 FDroidPopen(['unlink', dest], output=False)
1515 FDroidPopen(['rm', '-rf', dest], output=False)
1517 logging.info("...but it didn't exist")
1519 remove_signing_keys(build_dir)
1521 # Add required external libraries
1523 logging.info("Collecting prebuilt libraries")
1524 libsdir = os.path.join(root_dir, 'libs')
1525 if not os.path.exists(libsdir):
1527 for lib in build.extlibs:
1529 logging.info("...installing extlib {0}".format(lib))
1530 libf = os.path.basename(lib)
1531 libsrc = os.path.join(extlib_dir, lib)
1532 if not os.path.exists(libsrc):
1533 raise BuildException("Missing extlib file {0}".format(libsrc))
1534 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1536 # Run a pre-build command if one is required
1538 logging.info("Running 'prebuild' commands in %s" % root_dir)
1540 cmd = replace_config_vars(build.prebuild, build)
1542 # Substitute source library paths into prebuild commands
1543 for name, number, libpath in srclibpaths:
1544 libpath = os.path.relpath(libpath, root_dir)
1545 cmd = cmd.replace('$$' + name + '$$', libpath)
1547 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1548 if p.returncode != 0:
1549 raise BuildException("Error running prebuild command for %s:%s" %
1550 (app.id, build.versionName), p.output)
1552 # Generate (or update) the ant build file, build.xml...
1553 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1554 parms = ['android', 'update', 'lib-project']
1555 lparms = ['android', 'update', 'project']
1558 parms += ['-t', build.target]
1559 lparms += ['-t', build.target]
1560 if build.androidupdate:
1561 update_dirs = build.androidupdate
1563 update_dirs = ant_subprojects(root_dir) + ['.']
1565 for d in update_dirs:
1566 subdir = os.path.join(root_dir, d)
1568 logging.debug("Updating main project")
1569 cmd = parms + ['-p', d]
1571 logging.debug("Updating subproject %s" % d)
1572 cmd = lparms + ['-p', d]
1573 p = SdkToolsPopen(cmd, cwd=root_dir)
1574 # Check to see whether an error was returned without a proper exit
1575 # code (this is the case for the 'no target set or target invalid'
1577 if p.returncode != 0 or p.output.startswith("Error: "):
1578 raise BuildException("Failed to update project at %s" % d, p.output)
1579 # Clean update dirs via ant
1581 logging.info("Cleaning subproject %s" % d)
1582 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1584 return (root_dir, srclibpaths)
1587 # Extend via globbing the paths from a field and return them as a map from
1588 # original path to resulting paths
1589 def getpaths_map(build_dir, globpaths):
1593 full_path = os.path.join(build_dir, p)
1594 full_path = os.path.normpath(full_path)
1595 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1597 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1601 # Extend via globbing the paths from a field and return them as a set
1602 def getpaths(build_dir, globpaths):
1603 paths_map = getpaths_map(build_dir, globpaths)
1605 for k, v in paths_map.items():
1612 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1618 self.path = os.path.join('stats', 'known_apks.txt')
1620 if os.path.isfile(self.path):
1621 with open(self.path, 'r', encoding='utf8') as f:
1623 t = line.rstrip().split(' ')
1625 self.apks[t[0]] = (t[1], None)
1627 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1628 self.changed = False
1630 def writeifchanged(self):
1631 if not self.changed:
1634 if not os.path.exists('stats'):
1638 for apk, app in self.apks.items():
1640 line = apk + ' ' + appid
1642 line += ' ' + time.strftime('%Y-%m-%d', added)
1645 with open(self.path, 'w', encoding='utf8') as f:
1646 for line in sorted(lst, key=natural_key):
1647 f.write(line + '\n')
1649 # Record an apk (if it's new, otherwise does nothing)
1650 # Returns the date it was added.
1651 def recordapk(self, apk, app, default_date=None):
1652 if apk not in self.apks:
1653 if default_date is None:
1654 default_date = time.gmtime(time.time())
1655 self.apks[apk] = (app, default_date)
1657 _, added = self.apks[apk]
1660 # Look up information - given the 'apkname', returns (app id, date added/None).
1661 # Or returns None for an unknown apk.
1662 def getapp(self, apkname):
1663 if apkname in self.apks:
1664 return self.apks[apkname]
1667 # Get the most recent 'num' apps added to the repo, as a list of package ids
1668 # with the most recent first.
1669 def getlatest(self, num):
1671 for apk, app in self.apks.items():
1675 if apps[appid] > added:
1679 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1680 lst = [app for app, _ in sortedapps]
1685 def get_file_extension(filename):
1686 """get the normalized file extension, can be blank string but never None"""
1688 return os.path.splitext(filename)[1].lower()[1:]
1691 def isApkAndDebuggable(apkfile, config):
1692 """Returns True if the given file is an APK and is debuggable
1694 :param apkfile: full path to the apk to check"""
1696 if get_file_extension(apkfile) != 'apk':
1699 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1701 if p.returncode != 0:
1702 logging.critical("Failed to get apk manifest information")
1704 for line in p.output.splitlines():
1705 if 'android:debuggable' in line and not line.endswith('0x0'):
1712 self.returncode = None
1716 def SdkToolsPopen(commands, cwd=None, output=True):
1718 if cmd not in config:
1719 config[cmd] = find_sdk_tools_cmd(commands[0])
1720 abscmd = config[cmd]
1722 logging.critical("Could not find '%s' on your system" % cmd)
1725 test_aapt_version(config['aapt'])
1726 return FDroidPopen([abscmd] + commands[1:],
1727 cwd=cwd, output=output)
1730 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1732 Run a command and capture the possibly huge output as bytes.
1734 :param commands: command and argument list like in subprocess.Popen
1735 :param cwd: optionally specifies a working directory
1736 :returns: A PopenResult.
1741 set_FDroidPopen_env()
1744 cwd = os.path.normpath(cwd)
1745 logging.debug("Directory: %s" % cwd)
1746 logging.debug("> %s" % ' '.join(commands))
1748 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1749 result = PopenResult()
1752 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1753 stdout=subprocess.PIPE, stderr=stderr_param)
1754 except OSError as e:
1755 raise BuildException("OSError while trying to execute " +
1756 ' '.join(commands) + ': ' + str(e))
1758 if not stderr_to_stdout and options.verbose:
1759 stderr_queue = Queue()
1760 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1762 while not stderr_reader.eof():
1763 while not stderr_queue.empty():
1764 line = stderr_queue.get()
1765 sys.stderr.buffer.write(line)
1770 stdout_queue = Queue()
1771 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1774 # Check the queue for output (until there is no more to get)
1775 while not stdout_reader.eof():
1776 while not stdout_queue.empty():
1777 line = stdout_queue.get()
1778 if output and options.verbose:
1779 # Output directly to console
1780 sys.stderr.buffer.write(line)
1786 result.returncode = p.wait()
1787 result.output = buf.getvalue()
1792 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1794 Run a command and capture the possibly huge output as a str.
1796 :param commands: command and argument list like in subprocess.Popen
1797 :param cwd: optionally specifies a working directory
1798 :returns: A PopenResult.
1800 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1801 result.output = result.output.decode('utf-8', 'ignore')
1805 gradle_comment = re.compile(r'[ ]*//')
1806 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1807 gradle_line_matches = [
1808 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1809 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1810 re.compile(r'.*\.readLine\(.*'),
1814 def remove_signing_keys(build_dir):
1815 for root, dirs, files in os.walk(build_dir):
1816 if 'build.gradle' in files:
1817 path = os.path.join(root, 'build.gradle')
1819 with open(path, "r", encoding='utf8') as o:
1820 lines = o.readlines()
1826 with open(path, "w", encoding='utf8') as o:
1827 while i < len(lines):
1830 while line.endswith('\\\n'):
1831 line = line.rstrip('\\\n') + lines[i]
1834 if gradle_comment.match(line):
1839 opened += line.count('{')
1840 opened -= line.count('}')
1843 if gradle_signing_configs.match(line):
1848 if any(s.match(line) for s in gradle_line_matches):
1856 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1859 'project.properties',
1861 'default.properties',
1862 'ant.properties', ]:
1863 if propfile in files:
1864 path = os.path.join(root, propfile)
1866 with open(path, "r", encoding='iso-8859-1') as o:
1867 lines = o.readlines()
1871 with open(path, "w", encoding='iso-8859-1') as o:
1873 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1880 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1883 def set_FDroidPopen_env(build=None):
1885 set up the environment variables for the build environment
1887 There is only a weak standard, the variables used by gradle, so also set
1888 up the most commonly used environment variables for SDK and NDK. Also, if
1889 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1891 global env, orig_path
1895 orig_path = env['PATH']
1896 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1897 env[n] = config['sdk_path']
1898 for k, v in config['java_paths'].items():
1899 env['JAVA%s_HOME' % k] = v
1901 missinglocale = True
1902 for k, v in env.items():
1903 if k == 'LANG' and v != 'C':
1904 missinglocale = False
1906 missinglocale = False
1908 env['LANG'] = 'en_US.UTF-8'
1910 if build is not None:
1911 path = build.ndk_path()
1912 paths = orig_path.split(os.pathsep)
1913 if path not in paths:
1914 paths = [path] + paths
1915 env['PATH'] = os.pathsep.join(paths)
1916 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1917 env[n] = build.ndk_path()
1920 def replace_build_vars(cmd, build):
1921 cmd = cmd.replace('$$COMMIT$$', build.commit)
1922 cmd = cmd.replace('$$VERSION$$', build.versionName)
1923 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1927 def replace_config_vars(cmd, build):
1928 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1929 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1930 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1931 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1932 if build is not None:
1933 cmd = replace_build_vars(cmd, build)
1937 def place_srclib(root_dir, number, libpath):
1940 relpath = os.path.relpath(libpath, root_dir)
1941 proppath = os.path.join(root_dir, 'project.properties')
1944 if os.path.isfile(proppath):
1945 with open(proppath, "r", encoding='iso-8859-1') as o:
1946 lines = o.readlines()
1948 with open(proppath, "w", encoding='iso-8859-1') as o:
1951 if line.startswith('android.library.reference.%d=' % number):
1952 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1957 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1960 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1963 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1964 """Verify that two apks are the same
1966 One of the inputs is signed, the other is unsigned. The signature metadata
1967 is transferred from the signed to the unsigned apk, and then jarsigner is
1968 used to verify that the signature from the signed apk is also varlid for
1970 :param signed_apk: Path to a signed apk file
1971 :param unsigned_apk: Path to an unsigned apk file expected to match it
1972 :param tmp_dir: Path to directory for temporary files
1973 :returns: None if the verification is successful, otherwise a string
1974 describing what went wrong.
1976 with ZipFile(signed_apk) as signed_apk_as_zip:
1977 meta_inf_files = ['META-INF/MANIFEST.MF']
1978 for f in signed_apk_as_zip.namelist():
1979 if apk_sigfile.match(f):
1980 meta_inf_files.append(f)
1981 if len(meta_inf_files) < 3:
1982 return "Signature files missing from {0}".format(signed_apk)
1983 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1984 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1985 for meta_inf_file in meta_inf_files:
1986 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1988 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1989 logging.info("...NOT verified - {0}".format(signed_apk))
1990 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1991 logging.info("...successfully verified")
1995 apk_badchars = re.compile('''[/ :;'"]''')
1998 def compare_apks(apk1, apk2, tmp_dir):
2001 Returns None if the apk content is the same (apart from the signing key),
2002 otherwise a string describing what's different, or what went wrong when
2003 trying to do the comparison.
2006 absapk1 = os.path.abspath(apk1)
2007 absapk2 = os.path.abspath(apk2)
2009 # try to find diffoscope in the path, if it hasn't been manually configed
2010 if 'diffoscope' not in config:
2011 tmp = find_command('diffoscope')
2013 config['diffoscope'] = tmp
2014 if 'diffoscope' in config:
2015 htmlfile = absapk1 + '.diffoscope.html'
2016 textfile = absapk1 + '.diffoscope.txt'
2017 if subprocess.call([config['diffoscope'],
2018 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2019 '--html', htmlfile, '--text', textfile,
2020 absapk1, absapk2]) != 0:
2021 return("Failed to unpack " + apk1)
2023 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2024 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2025 for d in [apk1dir, apk2dir]:
2026 if os.path.exists(d):
2029 os.mkdir(os.path.join(d, 'jar-xf'))
2031 if subprocess.call(['jar', 'xf',
2032 os.path.abspath(apk1)],
2033 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2034 return("Failed to unpack " + apk1)
2035 if subprocess.call(['jar', 'xf',
2036 os.path.abspath(apk2)],
2037 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2038 return("Failed to unpack " + apk2)
2040 # try to find apktool in the path, if it hasn't been manually configed
2041 if 'apktool' not in config:
2042 tmp = find_command('apktool')
2044 config['apktool'] = tmp
2045 if 'apktool' in config:
2046 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2048 return("Failed to unpack " + apk1)
2049 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2051 return("Failed to unpack " + apk2)
2053 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2054 lines = p.output.splitlines()
2055 if len(lines) != 1 or 'META-INF' not in lines[0]:
2056 meld = find_command('meld')
2057 if meld is not None:
2058 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2059 return("Unexpected diff output - " + p.output)
2061 # since everything verifies, delete the comparison to keep cruft down
2062 shutil.rmtree(apk1dir)
2063 shutil.rmtree(apk2dir)
2065 # If we get here, it seems like they're the same!
2069 def find_command(command):
2070 '''find the full path of a command, or None if it can't be found in the PATH'''
2073 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2075 fpath, fname = os.path.split(command)
2080 for path in os.environ["PATH"].split(os.pathsep):
2081 path = path.strip('"')
2082 exe_file = os.path.join(path, command)
2083 if is_exe(exe_file):
2090 '''generate a random password for when generating keys'''
2091 h = hashlib.sha256()
2092 h.update(os.urandom(16)) # salt
2093 h.update(socket.getfqdn().encode('utf-8'))
2094 passwd = base64.b64encode(h.digest()).strip()
2095 return passwd.decode('utf-8')
2098 def genkeystore(localconfig):
2099 '''Generate a new key with random passwords and add it to new keystore'''
2100 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2101 keystoredir = os.path.dirname(localconfig['keystore'])
2102 if keystoredir is None or keystoredir == '':
2103 keystoredir = os.path.join(os.getcwd(), keystoredir)
2104 if not os.path.exists(keystoredir):
2105 os.makedirs(keystoredir, mode=0o700)
2107 write_password_file("keystorepass", localconfig['keystorepass'])
2108 write_password_file("keypass", localconfig['keypass'])
2109 p = FDroidPopen([config['keytool'], '-genkey',
2110 '-keystore', localconfig['keystore'],
2111 '-alias', localconfig['repo_keyalias'],
2112 '-keyalg', 'RSA', '-keysize', '4096',
2113 '-sigalg', 'SHA256withRSA',
2114 '-validity', '10000',
2115 '-storepass:file', config['keystorepassfile'],
2116 '-keypass:file', config['keypassfile'],
2117 '-dname', localconfig['keydname']])
2118 # TODO keypass should be sent via stdin
2119 if p.returncode != 0:
2120 raise BuildException("Failed to generate key", p.output)
2121 os.chmod(localconfig['keystore'], 0o0600)
2122 # now show the lovely key that was just generated
2123 p = FDroidPopen([config['keytool'], '-list', '-v',
2124 '-keystore', localconfig['keystore'],
2125 '-alias', localconfig['repo_keyalias'],
2126 '-storepass:file', config['keystorepassfile']])
2127 logging.info(p.output.strip() + '\n\n')
2130 def write_to_config(thisconfig, key, value=None):
2131 '''write a key/value to the local config.py'''
2133 origkey = key + '_orig'
2134 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2135 with open('config.py', 'r', encoding='utf8') as f:
2137 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2138 repl = '\n' + key + ' = "' + value + '"'
2139 data = re.sub(pattern, repl, data)
2140 # if this key is not in the file, append it
2141 if not re.match('\s*' + key + '\s*=\s*"', data):
2143 # make sure the file ends with a carraige return
2144 if not re.match('\n$', data):
2146 with open('config.py', 'w', encoding='utf8') as f:
2150 def parse_xml(path):
2151 return XMLElementTree.parse(path).getroot()
2154 def string_is_integer(string):
2162 def get_per_app_repos():
2163 '''per-app repos are dirs named with the packageName of a single app'''
2165 # Android packageNames are Java packages, they may contain uppercase or
2166 # lowercase letters ('A' through 'Z'), numbers, and underscores
2167 # ('_'). However, individual package name parts may only start with
2168 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2169 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2172 for root, dirs, files in os.walk(os.getcwd()):
2174 print('checking', root, 'for', d)
2175 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2176 # standard parts of an fdroid repo, so never packageNames
2179 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2185 def is_repo_file(filename):
2186 '''Whether the file in a repo is a build product to be delivered to users'''
2187 return os.path.isfile(filename) \
2188 and not filename.endswith('.asc') \
2189 and not filename.endswith('.sig') \
2190 and os.path.basename(filename) not in [
2192 'index_unsigned.jar',