3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # common.py is imported by all modules, so do not import third-party
21 # libraries here as they will become a requirement for all commands.
37 import xml.etree.ElementTree as XMLElementTree
39 from datetime import datetime
40 from distutils.version import LooseVersion
41 from queue import Queue
42 from zipfile import ZipFile
44 import fdroidserver.metadata
45 from .asynchronousfilereader import AsynchronousFileReader
48 XMLElementTree.register_namespace('android', 'http://schemas.android.com/apk/res/android')
57 'sdk_path': "$ANDROID_HOME",
62 'r12b': "$ANDROID_NDK",
66 'build_tools': "25.0.2",
67 'force_build_tools': False,
72 'accepted_formats': ['txt', 'yml'],
73 'sync_from_local_copy_dir': False,
74 'per_app_repos': False,
75 'make_current_version_link': True,
76 'current_version_name_source': 'Name',
77 'update_stats': False,
81 'stats_to_carbon': False,
83 'build_server_always': False,
84 'keystore': 'keystore.jks',
85 'smartcardoptions': [],
91 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
92 'repo_name': "My First FDroid Repo Demo",
93 'repo_icon': "fdroid-icon.png",
94 'repo_description': '''
95 This is a repository of apps to be used with FDroid. Applications in this
96 repository are either official binaries built by the original application
97 developers, or are binaries built from source by the admin of f-droid.org
98 using the tools on https://gitlab.com/u/fdroid.
104 def setup_global_opts(parser):
105 parser.add_argument("-v", "--verbose", action="store_true", default=False,
106 help="Spew out even more information than normal")
107 parser.add_argument("-q", "--quiet", action="store_true", default=False,
108 help="Restrict output to warnings and errors")
111 def fill_config_defaults(thisconfig):
112 for k, v in default_config.items():
113 if k not in thisconfig:
116 # Expand paths (~users and $vars)
117 def expand_path(path):
121 path = os.path.expanduser(path)
122 path = os.path.expandvars(path)
127 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
132 thisconfig[k + '_orig'] = v
134 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
135 if thisconfig['java_paths'] is None:
136 thisconfig['java_paths'] = dict()
138 pathlist += glob.glob('/usr/lib/jvm/j*[6-9]*')
139 pathlist += glob.glob('/usr/java/jdk1.[6-9]*')
140 pathlist += glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
141 pathlist += glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')
142 if os.getenv('JAVA_HOME') is not None:
143 pathlist.append(os.getenv('JAVA_HOME'))
144 if os.getenv('PROGRAMFILES') is not None:
145 pathlist += glob.glob(os.path.join(os.getenv('PROGRAMFILES'), 'Java', 'jdk1.[6-9].*'))
146 for d in sorted(pathlist):
147 if os.path.islink(d):
149 j = os.path.basename(d)
150 # the last one found will be the canonical one, so order appropriately
152 r'^1\.([6-9])\.0\.jdk$', # OSX
153 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
154 r'^jdk1\.([6-9])\.0_[0-9]+$', # Oracle Windows
155 r'^jdk([6-9])-openjdk$', # Arch
156 r'^java-([6-9])-openjdk$', # Arch
157 r'^java-([6-9])-jdk$', # Arch (oracle)
158 r'^java-1\.([6-9])\.0-.*$', # RedHat
159 r'^java-([6-9])-oracle$', # Debian WebUpd8
160 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
161 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
163 m = re.match(regex, j)
166 for p in [d, os.path.join(d, 'Contents', 'Home')]:
167 if os.path.exists(os.path.join(p, 'bin', 'javac')):
168 thisconfig['java_paths'][m.group(1)] = p
170 for java_version in ('7', '8', '9'):
171 if java_version not in thisconfig['java_paths']:
173 java_home = thisconfig['java_paths'][java_version]
174 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
175 if os.path.exists(jarsigner):
176 thisconfig['jarsigner'] = jarsigner
177 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
178 break # Java7 is preferred, so quit if found
180 for k in ['ndk_paths', 'java_paths']:
186 thisconfig[k][k2] = exp
187 thisconfig[k][k2 + '_orig'] = v
190 def regsub_file(pattern, repl, path):
191 with open(path, 'rb') as f:
193 text = re.sub(bytes(pattern, 'utf8'), bytes(repl, 'utf8'), text)
194 with open(path, 'wb') as f:
198 def read_config(opts, config_file='config.py'):
199 """Read the repository config
201 The config is read from config_file, which is in the current
202 directory when any of the repo management commands are used. If
203 there is a local metadata file in the git repo, then config.py is
204 not required, just use defaults.
207 global config, options
209 if config is not None:
216 if os.path.isfile(config_file):
217 logging.debug("Reading %s" % config_file)
218 with io.open(config_file, "rb") as f:
219 code = compile(f.read(), config_file, 'exec')
220 exec(code, None, config)
221 elif len(get_local_metadata_files()) == 0:
222 logging.critical("Missing config file - is this a repo directory?")
225 for k in ('mirrors', 'install_list', 'uninstall_list', 'serverwebroot', 'servergitroot'):
227 if not type(config[k]) in (str, list, tuple):
228 logging.warn('"' + k + '" will be in random order!'
229 + ' Use () or [] brackets if order is important!')
231 # smartcardoptions must be a list since its command line args for Popen
232 if 'smartcardoptions' in config:
233 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
234 elif 'keystore' in config and config['keystore'] == 'NONE':
235 # keystore='NONE' means use smartcard, these are required defaults
236 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
237 'SunPKCS11-OpenSC', '-providerClass',
238 'sun.security.pkcs11.SunPKCS11',
239 '-providerArg', 'opensc-fdroid.cfg']
241 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
242 st = os.stat(config_file)
243 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
244 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
246 fill_config_defaults(config)
248 for k in ["keystorepass", "keypass"]:
250 write_password_file(k)
252 for k in ["repo_description", "archive_description"]:
254 config[k] = clean_description(config[k])
256 if 'serverwebroot' in config:
257 if isinstance(config['serverwebroot'], str):
258 roots = [config['serverwebroot']]
259 elif all(isinstance(item, str) for item in config['serverwebroot']):
260 roots = config['serverwebroot']
262 raise TypeError('only accepts strings, lists, and tuples')
264 for rootstr in roots:
265 # since this is used with rsync, where trailing slashes have
266 # meaning, ensure there is always a trailing slash
267 if rootstr[-1] != '/':
269 rootlist.append(rootstr.replace('//', '/'))
270 config['serverwebroot'] = rootlist
272 if 'servergitmirrors' in config:
273 if isinstance(config['servergitmirrors'], str):
274 roots = [config['servergitmirrors']]
275 elif all(isinstance(item, str) for item in config['servergitmirrors']):
276 roots = config['servergitmirrors']
278 raise TypeError('only accepts strings, lists, and tuples')
279 config['servergitmirrors'] = roots
284 def find_sdk_tools_cmd(cmd):
285 '''find a working path to a tool from the Android SDK'''
288 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
289 # try to find a working path to this command, in all the recent possible paths
290 if 'build_tools' in config:
291 build_tools = os.path.join(config['sdk_path'], 'build-tools')
292 # if 'build_tools' was manually set and exists, check only that one
293 configed_build_tools = os.path.join(build_tools, config['build_tools'])
294 if os.path.exists(configed_build_tools):
295 tooldirs.append(configed_build_tools)
297 # no configed version, so hunt known paths for it
298 for f in sorted(os.listdir(build_tools), reverse=True):
299 if os.path.isdir(os.path.join(build_tools, f)):
300 tooldirs.append(os.path.join(build_tools, f))
301 tooldirs.append(build_tools)
302 sdk_tools = os.path.join(config['sdk_path'], 'tools')
303 if os.path.exists(sdk_tools):
304 tooldirs.append(sdk_tools)
305 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
306 if os.path.exists(sdk_platform_tools):
307 tooldirs.append(sdk_platform_tools)
308 tooldirs.append('/usr/bin')
310 path = os.path.join(d, cmd)
311 if os.path.isfile(path):
313 test_aapt_version(path)
315 # did not find the command, exit with error message
316 ensure_build_tools_exists(config)
319 def test_aapt_version(aapt):
320 '''Check whether the version of aapt is new enough'''
321 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
322 if output is None or output == '':
323 logging.error(aapt + ' failed to execute!')
325 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
330 # the Debian package has the version string like "v0.2-23.0.2"
331 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
332 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
334 logging.warning('Unknown version of aapt, might cause problems: ' + output)
337 def test_sdk_exists(thisconfig):
338 if 'sdk_path' not in thisconfig:
339 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
340 test_aapt_version(thisconfig['aapt'])
343 logging.error("'sdk_path' not set in config.py!")
345 if thisconfig['sdk_path'] == default_config['sdk_path']:
346 logging.error('No Android SDK found!')
347 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
348 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
350 if not os.path.exists(thisconfig['sdk_path']):
351 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
353 if not os.path.isdir(thisconfig['sdk_path']):
354 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
356 for d in ['build-tools', 'platform-tools', 'tools']:
357 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
358 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
359 thisconfig['sdk_path'], d))
364 def ensure_build_tools_exists(thisconfig):
365 if not test_sdk_exists(thisconfig):
367 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
368 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
369 if not os.path.isdir(versioned_build_tools):
370 logging.critical('Android Build Tools path "'
371 + versioned_build_tools + '" does not exist!')
375 def write_password_file(pwtype, password=None):
377 writes out passwords to a protected file instead of passing passwords as
378 command line argments
380 filename = '.fdroid.' + pwtype + '.txt'
381 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
383 os.write(fd, config[pwtype].encode('utf-8'))
385 os.write(fd, password.encode('utf-8'))
387 config[pwtype + 'file'] = filename
390 def get_local_metadata_files():
391 '''get any metadata files local to an app's source repo
393 This tries to ignore anything that does not count as app metdata,
394 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
397 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
400 def read_pkg_args(args, allow_vercodes=False):
402 Given the arguments in the form of multiple appid:[vc] strings, this returns
403 a dictionary with the set of vercodes specified for each package.
411 if allow_vercodes and ':' in p:
412 package, vercode = p.split(':')
414 package, vercode = p, None
415 if package not in vercodes:
416 vercodes[package] = [vercode] if vercode else []
418 elif vercode and vercode not in vercodes[package]:
419 vercodes[package] += [vercode] if vercode else []
424 def read_app_args(args, allapps, allow_vercodes=False):
426 On top of what read_pkg_args does, this returns the whole app metadata, but
427 limiting the builds list to the builds matching the vercodes specified.
430 vercodes = read_pkg_args(args, allow_vercodes)
436 for appid, app in allapps.items():
437 if appid in vercodes:
440 if len(apps) != len(vercodes):
443 logging.critical("No such package: %s" % p)
444 raise FDroidException("Found invalid app ids in arguments")
446 raise FDroidException("No packages specified")
449 for appid, app in apps.items():
453 app.builds = [b for b in app.builds if b.versionCode in vc]
454 if len(app.builds) != len(vercodes[appid]):
456 allvcs = [b.versionCode for b in app.builds]
457 for v in vercodes[appid]:
459 logging.critical("No such vercode %s for app %s" % (v, appid))
462 raise FDroidException("Found invalid vercodes for some apps")
467 def get_extension(filename):
468 base, ext = os.path.splitext(filename)
471 return base, ext.lower()[1:]
474 def has_extension(filename, ext):
475 _, f_ext = get_extension(filename)
479 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
482 def clean_description(description):
483 'Remove unneeded newlines and spaces from a block of description text'
485 # this is split up by paragraph to make removing the newlines easier
486 for paragraph in re.split(r'\n\n', description):
487 paragraph = re.sub('\r', '', paragraph)
488 paragraph = re.sub('\n', ' ', paragraph)
489 paragraph = re.sub(' {2,}', ' ', paragraph)
490 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
491 returnstring += paragraph + '\n\n'
492 return returnstring.rstrip('\n')
495 def publishednameinfo(filename):
496 filename = os.path.basename(filename)
497 m = publish_name_regex.match(filename)
499 result = (m.group(1), m.group(2))
500 except AttributeError:
501 raise FDroidException("Invalid name for published file: %s" % filename)
505 def get_release_filename(app, build):
507 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
509 return "%s_%s.apk" % (app.id, build.versionCode)
512 def getsrcname(app, build):
513 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
525 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
528 def get_build_dir(app):
529 '''get the dir that this app will be built in'''
531 if app.RepoType == 'srclib':
532 return os.path.join('build', 'srclib', app.Repo)
534 return os.path.join('build', app.id)
538 '''checkout code from VCS and return instance of vcs and the build dir'''
539 build_dir = get_build_dir(app)
541 # Set up vcs interface and make sure we have the latest code...
542 logging.debug("Getting {0} vcs interface for {1}"
543 .format(app.RepoType, app.Repo))
544 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
548 vcs = getvcs(app.RepoType, remote, build_dir)
550 return vcs, build_dir
553 def getvcs(vcstype, remote, local):
555 return vcs_git(remote, local)
556 if vcstype == 'git-svn':
557 return vcs_gitsvn(remote, local)
559 return vcs_hg(remote, local)
561 return vcs_bzr(remote, local)
562 if vcstype == 'srclib':
563 if local != os.path.join('build', 'srclib', remote):
564 raise VCSException("Error: srclib paths are hard-coded!")
565 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
567 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
568 raise VCSException("Invalid vcs type " + vcstype)
571 def getsrclibvcs(name):
572 if name not in fdroidserver.metadata.srclibs:
573 raise VCSException("Missing srclib " + name)
574 return fdroidserver.metadata.srclibs[name]['Repo Type']
579 def __init__(self, remote, local):
581 # svn, git-svn and bzr may require auth
583 if self.repotype() in ('git-svn', 'bzr'):
585 if self.repotype == 'git-svn':
586 raise VCSException("Authentication is not supported for git-svn")
587 self.username, remote = remote.split('@')
588 if ':' not in self.username:
589 raise VCSException("Password required with username")
590 self.username, self.password = self.username.split(':')
594 self.clone_failed = False
595 self.refreshed = False
601 # Take the local repository to a clean version of the given revision, which
602 # is specificed in the VCS's native format. Beforehand, the repository can
603 # be dirty, or even non-existent. If the repository does already exist
604 # locally, it will be updated from the origin, but only once in the
605 # lifetime of the vcs object.
606 # None is acceptable for 'rev' if you know you are cloning a clean copy of
607 # the repo - otherwise it must specify a valid revision.
608 def gotorevision(self, rev, refresh=True):
610 if self.clone_failed:
611 raise VCSException("Downloading the repository already failed once, not trying again.")
613 # The .fdroidvcs-id file for a repo tells us what VCS type
614 # and remote that directory was created from, allowing us to drop it
615 # automatically if either of those things changes.
616 fdpath = os.path.join(self.local, '..',
617 '.fdroidvcs-' + os.path.basename(self.local))
618 fdpath = os.path.normpath(fdpath)
619 cdata = self.repotype() + ' ' + self.remote
622 if os.path.exists(self.local):
623 if os.path.exists(fdpath):
624 with open(fdpath, 'r') as f:
625 fsdata = f.read().strip()
630 logging.info("Repository details for %s changed - deleting" % (
634 logging.info("Repository details for %s missing - deleting" % (
637 shutil.rmtree(self.local)
641 self.refreshed = True
644 self.gotorevisionx(rev)
645 except FDroidException as e:
648 # If necessary, write the .fdroidvcs file.
649 if writeback and not self.clone_failed:
650 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
651 with open(fdpath, 'w+') as f:
657 # Derived classes need to implement this. It's called once basic checking
658 # has been performend.
659 def gotorevisionx(self, rev):
660 raise VCSException("This VCS type doesn't define gotorevisionx")
662 # Initialise and update submodules
663 def initsubmodules(self):
664 raise VCSException('Submodules not supported for this vcs type')
666 # Get a list of all known tags
668 if not self._gettags:
669 raise VCSException('gettags not supported for this vcs type')
671 for tag in self._gettags():
672 if re.match('[-A-Za-z0-9_. /]+$', tag):
676 # Get a list of all the known tags, sorted from newest to oldest
677 def latesttags(self):
678 raise VCSException('latesttags not supported for this vcs type')
680 # Get current commit reference (hash, revision, etc)
682 raise VCSException('getref not supported for this vcs type')
684 # Returns the srclib (name, path) used in setting up the current
695 # If the local directory exists, but is somehow not a git repository, git
696 # will traverse up the directory tree until it finds one that is (i.e.
697 # fdroidserver) and then we'll proceed to destroy it! This is called as
700 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
701 result = p.output.rstrip()
702 if not result.endswith(self.local):
703 raise VCSException('Repository mismatch')
705 def gotorevisionx(self, rev):
706 if not os.path.exists(self.local):
708 p = FDroidPopen(['git', 'clone', self.remote, self.local])
709 if p.returncode != 0:
710 self.clone_failed = True
711 raise VCSException("Git clone failed", p.output)
715 # Discard any working tree changes
716 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
717 'git', 'reset', '--hard'], cwd=self.local, output=False)
718 if p.returncode != 0:
719 raise VCSException("Git reset failed", p.output)
720 # Remove untracked files now, in case they're tracked in the target
721 # revision (it happens!)
722 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
723 'git', 'clean', '-dffx'], cwd=self.local, output=False)
724 if p.returncode != 0:
725 raise VCSException("Git clean failed", p.output)
726 if not self.refreshed:
727 # Get latest commits and tags from remote
728 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
729 if p.returncode != 0:
730 raise VCSException("Git fetch failed", p.output)
731 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
732 if p.returncode != 0:
733 raise VCSException("Git fetch failed", p.output)
734 # Recreate origin/HEAD as git clone would do it, in case it disappeared
735 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
736 if p.returncode != 0:
737 lines = p.output.splitlines()
738 if 'Multiple remote HEAD branches' not in lines[0]:
739 raise VCSException("Git remote set-head failed", p.output)
740 branch = lines[1].split(' ')[-1]
741 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
742 if p2.returncode != 0:
743 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
744 self.refreshed = True
745 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
746 # a github repo. Most of the time this is the same as origin/master.
747 rev = rev or 'origin/HEAD'
748 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
749 if p.returncode != 0:
750 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
751 # Get rid of any uncontrolled files left behind
752 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
753 if p.returncode != 0:
754 raise VCSException("Git clean failed", p.output)
756 def initsubmodules(self):
758 submfile = os.path.join(self.local, '.gitmodules')
759 if not os.path.isfile(submfile):
760 raise VCSException("No git submodules available")
762 # fix submodules not accessible without an account and public key auth
763 with open(submfile, 'r') as f:
764 lines = f.readlines()
765 with open(submfile, 'w') as f:
767 if 'git@github.com' in line:
768 line = line.replace('git@github.com:', 'https://github.com/')
769 if 'git@gitlab.com' in line:
770 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
773 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
774 if p.returncode != 0:
775 raise VCSException("Git submodule sync failed", p.output)
776 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
777 if p.returncode != 0:
778 raise VCSException("Git submodule update failed", p.output)
782 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
783 return p.output.splitlines()
785 tag_format = re.compile(r'tag: ([^),]*)')
787 def latesttags(self):
789 p = FDroidPopen(['git', 'log', '--tags',
790 '--simplify-by-decoration', '--pretty=format:%d'],
791 cwd=self.local, output=False)
793 for line in p.output.splitlines():
794 for tag in self.tag_format.findall(line):
799 class vcs_gitsvn(vcs):
804 # If the local directory exists, but is somehow not a git repository, git
805 # will traverse up the directory tree until it finds one that is (i.e.
806 # fdroidserver) and then we'll proceed to destory it! This is called as
809 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
810 result = p.output.rstrip()
811 if not result.endswith(self.local):
812 raise VCSException('Repository mismatch')
814 def gotorevisionx(self, rev):
815 if not os.path.exists(self.local):
817 gitsvn_args = ['git', 'svn', 'clone']
818 if ';' in self.remote:
819 remote_split = self.remote.split(';')
820 for i in remote_split[1:]:
821 if i.startswith('trunk='):
822 gitsvn_args.extend(['-T', i[6:]])
823 elif i.startswith('tags='):
824 gitsvn_args.extend(['-t', i[5:]])
825 elif i.startswith('branches='):
826 gitsvn_args.extend(['-b', i[9:]])
827 gitsvn_args.extend([remote_split[0], self.local])
828 p = FDroidPopen(gitsvn_args, output=False)
829 if p.returncode != 0:
830 self.clone_failed = True
831 raise VCSException("Git svn clone failed", p.output)
833 gitsvn_args.extend([self.remote, self.local])
834 p = FDroidPopen(gitsvn_args, output=False)
835 if p.returncode != 0:
836 self.clone_failed = True
837 raise VCSException("Git svn clone failed", p.output)
841 # Discard any working tree changes
842 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
843 if p.returncode != 0:
844 raise VCSException("Git reset failed", p.output)
845 # Remove untracked files now, in case they're tracked in the target
846 # revision (it happens!)
847 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
848 if p.returncode != 0:
849 raise VCSException("Git clean failed", p.output)
850 if not self.refreshed:
851 # Get new commits, branches and tags from repo
852 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
853 if p.returncode != 0:
854 raise VCSException("Git svn fetch failed")
855 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
856 if p.returncode != 0:
857 raise VCSException("Git svn rebase failed", p.output)
858 self.refreshed = True
860 rev = rev or 'master'
862 nospaces_rev = rev.replace(' ', '%20')
863 # Try finding a svn tag
864 for treeish in ['origin/', '']:
865 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
866 if p.returncode == 0:
868 if p.returncode != 0:
869 # No tag found, normal svn rev translation
870 # Translate svn rev into git format
871 rev_split = rev.split('/')
874 for treeish in ['origin/', '']:
875 if len(rev_split) > 1:
876 treeish += rev_split[0]
877 svn_rev = rev_split[1]
880 # if no branch is specified, then assume trunk (i.e. 'master' branch):
884 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
886 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
887 git_rev = p.output.rstrip()
889 if p.returncode == 0 and git_rev:
892 if p.returncode != 0 or not git_rev:
893 # Try a plain git checkout as a last resort
894 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
895 if p.returncode != 0:
896 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
898 # Check out the git rev equivalent to the svn rev
899 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
900 if p.returncode != 0:
901 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
903 # Get rid of any uncontrolled files left behind
904 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
905 if p.returncode != 0:
906 raise VCSException("Git clean failed", p.output)
910 for treeish in ['origin/', '']:
911 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
917 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
918 if p.returncode != 0:
920 return p.output.strip()
928 def gotorevisionx(self, rev):
929 if not os.path.exists(self.local):
930 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
931 if p.returncode != 0:
932 self.clone_failed = True
933 raise VCSException("Hg clone failed", p.output)
935 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
936 if p.returncode != 0:
937 raise VCSException("Hg status failed", p.output)
938 for line in p.output.splitlines():
939 if not line.startswith('? '):
940 raise VCSException("Unexpected output from hg status -uS: " + line)
941 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
942 if not self.refreshed:
943 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
944 if p.returncode != 0:
945 raise VCSException("Hg pull failed", p.output)
946 self.refreshed = True
948 rev = rev or 'default'
951 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
952 if p.returncode != 0:
953 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
954 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
955 # Also delete untracked files, we have to enable purge extension for that:
956 if "'purge' is provided by the following extension" in p.output:
957 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
958 myfile.write("\n[extensions]\nhgext.purge=\n")
959 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
960 if p.returncode != 0:
961 raise VCSException("HG purge failed", p.output)
962 elif p.returncode != 0:
963 raise VCSException("HG purge failed", p.output)
966 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
967 return p.output.splitlines()[1:]
975 def gotorevisionx(self, rev):
976 if not os.path.exists(self.local):
977 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
978 if p.returncode != 0:
979 self.clone_failed = True
980 raise VCSException("Bzr branch failed", p.output)
982 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
983 if p.returncode != 0:
984 raise VCSException("Bzr revert failed", p.output)
985 if not self.refreshed:
986 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
987 if p.returncode != 0:
988 raise VCSException("Bzr update failed", p.output)
989 self.refreshed = True
991 revargs = list(['-r', rev] if rev else [])
992 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
993 if p.returncode != 0:
994 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
997 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
998 return [tag.split(' ')[0].strip() for tag in
999 p.output.splitlines()]
1002 def unescape_string(string):
1005 if string[0] == '"' and string[-1] == '"':
1008 return string.replace("\\'", "'")
1011 def retrieve_string(app_dir, string, xmlfiles=None):
1013 if not string.startswith('@string/'):
1014 return unescape_string(string)
1016 if xmlfiles is None:
1019 os.path.join(app_dir, 'res'),
1020 os.path.join(app_dir, 'src', 'main', 'res'),
1022 for r, d, f in os.walk(res_dir):
1023 if os.path.basename(r) == 'values':
1024 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1026 name = string[len('@string/'):]
1028 def element_content(element):
1029 if element.text is None:
1031 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1032 return s.decode('utf-8').strip()
1034 for path in xmlfiles:
1035 if not os.path.isfile(path):
1037 xml = parse_xml(path)
1038 element = xml.find('string[@name="' + name + '"]')
1039 if element is not None:
1040 content = element_content(element)
1041 return retrieve_string(app_dir, content, xmlfiles)
1046 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1047 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1050 def manifest_paths(app_dir, flavours):
1051 '''Return list of existing files that will be used to find the highest vercode'''
1053 possible_manifests = \
1054 [os.path.join(app_dir, 'AndroidManifest.xml'),
1055 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1056 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1057 os.path.join(app_dir, 'build.gradle')]
1059 for flavour in flavours:
1060 if flavour == 'yes':
1062 possible_manifests.append(
1063 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1065 return [path for path in possible_manifests if os.path.isfile(path)]
1068 def fetch_real_name(app_dir, flavours):
1069 '''Retrieve the package name. Returns the name, or None if not found.'''
1070 for path in manifest_paths(app_dir, flavours):
1071 if not has_extension(path, 'xml') or not os.path.isfile(path):
1073 logging.debug("fetch_real_name: Checking manifest at " + path)
1074 xml = parse_xml(path)
1075 app = xml.find('application')
1078 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1080 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1081 result = retrieve_string_singleline(app_dir, label)
1083 result = result.strip()
1088 def get_library_references(root_dir):
1090 proppath = os.path.join(root_dir, 'project.properties')
1091 if not os.path.isfile(proppath):
1093 with open(proppath, 'r', encoding='iso-8859-1') as f:
1095 if not line.startswith('android.library.reference.'):
1097 path = line.split('=')[1].strip()
1098 relpath = os.path.join(root_dir, path)
1099 if not os.path.isdir(relpath):
1101 logging.debug("Found subproject at %s" % path)
1102 libraries.append(path)
1106 def ant_subprojects(root_dir):
1107 subprojects = get_library_references(root_dir)
1108 for subpath in subprojects:
1109 subrelpath = os.path.join(root_dir, subpath)
1110 for p in get_library_references(subrelpath):
1111 relp = os.path.normpath(os.path.join(subpath, p))
1112 if relp not in subprojects:
1113 subprojects.insert(0, relp)
1117 def remove_debuggable_flags(root_dir):
1118 # Remove forced debuggable flags
1119 logging.debug("Removing debuggable flags from %s" % root_dir)
1120 for root, dirs, files in os.walk(root_dir):
1121 if 'AndroidManifest.xml' in files:
1122 regsub_file(r'android:debuggable="[^"]*"',
1124 os.path.join(root, 'AndroidManifest.xml'))
1127 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1128 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1129 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1132 def app_matches_packagename(app, package):
1135 appid = app.UpdateCheckName or app.id
1136 if appid is None or appid == "Ignore":
1138 return appid == package
1141 def parse_androidmanifests(paths, app):
1143 Extract some information from the AndroidManifest.xml at the given path.
1144 Returns (version, vercode, package), any or all of which might be None.
1145 All values returned are strings.
1148 ignoreversions = app.UpdateCheckIgnore
1149 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1152 return (None, None, None)
1160 if not os.path.isfile(path):
1163 logging.debug("Parsing manifest at {0}".format(path))
1168 if has_extension(path, 'gradle'):
1169 with open(path, 'r') as f:
1171 if gradle_comment.match(line):
1173 # Grab first occurence of each to avoid running into
1174 # alternative flavours and builds.
1176 matches = psearch_g(line)
1178 s = matches.group(2)
1179 if app_matches_packagename(app, s):
1182 matches = vnsearch_g(line)
1184 version = matches.group(2)
1186 matches = vcsearch_g(line)
1188 vercode = matches.group(1)
1191 xml = parse_xml(path)
1192 if "package" in xml.attrib:
1193 s = xml.attrib["package"]
1194 if app_matches_packagename(app, s):
1196 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1197 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1198 base_dir = os.path.dirname(path)
1199 version = retrieve_string_singleline(base_dir, version)
1200 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1201 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1202 if string_is_integer(a):
1205 logging.warning("Problem with xml at {0}".format(path))
1207 # Remember package name, may be defined separately from version+vercode
1209 package = max_package
1211 logging.debug("..got package={0}, version={1}, vercode={2}"
1212 .format(package, version, vercode))
1214 # Always grab the package name and version name in case they are not
1215 # together with the highest version code
1216 if max_package is None and package is not None:
1217 max_package = package
1218 if max_version is None and version is not None:
1219 max_version = version
1221 if vercode is not None \
1222 and (max_vercode is None or vercode > max_vercode):
1223 if not ignoresearch or not ignoresearch(version):
1224 if version is not None:
1225 max_version = version
1226 if vercode is not None:
1227 max_vercode = vercode
1228 if package is not None:
1229 max_package = package
1231 max_version = "Ignore"
1233 if max_version is None:
1234 max_version = "Unknown"
1236 if max_package and not is_valid_package_name(max_package):
1237 raise FDroidException("Invalid package name {0}".format(max_package))
1239 return (max_version, max_vercode, max_package)
1242 def is_valid_package_name(name):
1243 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1246 class FDroidException(Exception):
1248 def __init__(self, value, detail=None):
1250 self.detail = detail
1252 def shortened_detail(self):
1253 if len(self.detail) < 16000:
1255 return '[...]\n' + self.detail[-16000:]
1257 def get_wikitext(self):
1258 ret = repr(self.value) + "\n"
1261 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1267 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1271 class VCSException(FDroidException):
1275 class BuildException(FDroidException):
1279 # Get the specified source library.
1280 # Returns the path to it. Normally this is the path to be used when referencing
1281 # it, which may be a subdirectory of the actual project. If you want the base
1282 # directory of the project, pass 'basepath=True'.
1283 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1284 raw=False, prepare=True, preponly=False, refresh=True,
1293 name, ref = spec.split('@')
1295 number, name = name.split(':', 1)
1297 name, subdir = name.split('/', 1)
1299 if name not in fdroidserver.metadata.srclibs:
1300 raise VCSException('srclib ' + name + ' not found.')
1302 srclib = fdroidserver.metadata.srclibs[name]
1304 sdir = os.path.join(srclib_dir, name)
1307 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1308 vcs.srclib = (name, number, sdir)
1310 vcs.gotorevision(ref, refresh)
1317 libdir = os.path.join(sdir, subdir)
1318 elif srclib["Subdir"]:
1319 for subdir in srclib["Subdir"]:
1320 libdir_candidate = os.path.join(sdir, subdir)
1321 if os.path.exists(libdir_candidate):
1322 libdir = libdir_candidate
1328 remove_signing_keys(sdir)
1329 remove_debuggable_flags(sdir)
1333 if srclib["Prepare"]:
1334 cmd = replace_config_vars(srclib["Prepare"], build)
1336 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1337 if p.returncode != 0:
1338 raise BuildException("Error running prepare command for srclib %s"
1344 return (name, number, libdir)
1347 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1350 # Prepare the source code for a particular build
1351 # 'vcs' - the appropriate vcs object for the application
1352 # 'app' - the application details from the metadata
1353 # 'build' - the build details from the metadata
1354 # 'build_dir' - the path to the build directory, usually
1356 # 'srclib_dir' - the path to the source libraries directory, usually
1358 # 'extlib_dir' - the path to the external libraries directory, usually
1360 # Returns the (root, srclibpaths) where:
1361 # 'root' is the root directory, which may be the same as 'build_dir' or may
1362 # be a subdirectory of it.
1363 # 'srclibpaths' is information on the srclibs being used
1364 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1366 # Optionally, the actual app source can be in a subdirectory
1368 root_dir = os.path.join(build_dir, build.subdir)
1370 root_dir = build_dir
1372 # Get a working copy of the right revision
1373 logging.info("Getting source for revision " + build.commit)
1374 vcs.gotorevision(build.commit, refresh)
1376 # Initialise submodules if required
1377 if build.submodules:
1378 logging.info("Initialising submodules")
1379 vcs.initsubmodules()
1381 # Check that a subdir (if we're using one) exists. This has to happen
1382 # after the checkout, since it might not exist elsewhere
1383 if not os.path.exists(root_dir):
1384 raise BuildException('Missing subdir ' + root_dir)
1386 # Run an init command if one is required
1388 cmd = replace_config_vars(build.init, build)
1389 logging.info("Running 'init' commands in %s" % root_dir)
1391 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1392 if p.returncode != 0:
1393 raise BuildException("Error running init command for %s:%s" %
1394 (app.id, build.versionName), p.output)
1396 # Apply patches if any
1398 logging.info("Applying patches")
1399 for patch in build.patch:
1400 patch = patch.strip()
1401 logging.info("Applying " + patch)
1402 patch_path = os.path.join('metadata', app.id, patch)
1403 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1404 if p.returncode != 0:
1405 raise BuildException("Failed to apply patch %s" % patch_path)
1407 # Get required source libraries
1410 logging.info("Collecting source libraries")
1411 for lib in build.srclibs:
1412 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1413 refresh=refresh, build=build))
1415 for name, number, libpath in srclibpaths:
1416 place_srclib(root_dir, int(number) if number else None, libpath)
1418 basesrclib = vcs.getsrclib()
1419 # If one was used for the main source, add that too.
1421 srclibpaths.append(basesrclib)
1423 # Update the local.properties file
1424 localprops = [os.path.join(build_dir, 'local.properties')]
1426 parts = build.subdir.split(os.sep)
1429 cur = os.path.join(cur, d)
1430 localprops += [os.path.join(cur, 'local.properties')]
1431 for path in localprops:
1433 if os.path.isfile(path):
1434 logging.info("Updating local.properties file at %s" % path)
1435 with open(path, 'r', encoding='iso-8859-1') as f:
1439 logging.info("Creating local.properties file at %s" % path)
1440 # Fix old-fashioned 'sdk-location' by copying
1441 # from sdk.dir, if necessary
1443 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1444 re.S | re.M).group(1)
1445 props += "sdk-location=%s\n" % sdkloc
1447 props += "sdk.dir=%s\n" % config['sdk_path']
1448 props += "sdk-location=%s\n" % config['sdk_path']
1449 ndk_path = build.ndk_path()
1450 # if for any reason the path isn't valid or the directory
1451 # doesn't exist, some versions of Gradle will error with a
1452 # cryptic message (even if the NDK is not even necessary).
1453 # https://gitlab.com/fdroid/fdroidserver/issues/171
1454 if ndk_path and os.path.exists(ndk_path):
1456 props += "ndk.dir=%s\n" % ndk_path
1457 props += "ndk-location=%s\n" % ndk_path
1458 # Add java.encoding if necessary
1460 props += "java.encoding=%s\n" % build.encoding
1461 with open(path, 'w', encoding='iso-8859-1') as f:
1465 if build.build_method() == 'gradle':
1466 flavours = build.gradle
1469 n = build.target.split('-')[1]
1470 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1471 r'compileSdkVersion %s' % n,
1472 os.path.join(root_dir, 'build.gradle'))
1474 # Remove forced debuggable flags
1475 remove_debuggable_flags(root_dir)
1477 # Insert version code and number into the manifest if necessary
1478 if build.forceversion:
1479 logging.info("Changing the version name")
1480 for path in manifest_paths(root_dir, flavours):
1481 if not os.path.isfile(path):
1483 if has_extension(path, 'xml'):
1484 regsub_file(r'android:versionName="[^"]*"',
1485 r'android:versionName="%s"' % build.versionName,
1487 elif has_extension(path, 'gradle'):
1488 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1489 r"""\1versionName '%s'""" % build.versionName,
1492 if build.forcevercode:
1493 logging.info("Changing the version code")
1494 for path in manifest_paths(root_dir, flavours):
1495 if not os.path.isfile(path):
1497 if has_extension(path, 'xml'):
1498 regsub_file(r'android:versionCode="[^"]*"',
1499 r'android:versionCode="%s"' % build.versionCode,
1501 elif has_extension(path, 'gradle'):
1502 regsub_file(r'versionCode[ =]+[0-9]+',
1503 r'versionCode %s' % build.versionCode,
1506 # Delete unwanted files
1508 logging.info("Removing specified files")
1509 for part in getpaths(build_dir, build.rm):
1510 dest = os.path.join(build_dir, part)
1511 logging.info("Removing {0}".format(part))
1512 if os.path.lexists(dest):
1513 if os.path.islink(dest):
1514 FDroidPopen(['unlink', dest], output=False)
1516 FDroidPopen(['rm', '-rf', dest], output=False)
1518 logging.info("...but it didn't exist")
1520 remove_signing_keys(build_dir)
1522 # Add required external libraries
1524 logging.info("Collecting prebuilt libraries")
1525 libsdir = os.path.join(root_dir, 'libs')
1526 if not os.path.exists(libsdir):
1528 for lib in build.extlibs:
1530 logging.info("...installing extlib {0}".format(lib))
1531 libf = os.path.basename(lib)
1532 libsrc = os.path.join(extlib_dir, lib)
1533 if not os.path.exists(libsrc):
1534 raise BuildException("Missing extlib file {0}".format(libsrc))
1535 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1537 # Run a pre-build command if one is required
1539 logging.info("Running 'prebuild' commands in %s" % root_dir)
1541 cmd = replace_config_vars(build.prebuild, build)
1543 # Substitute source library paths into prebuild commands
1544 for name, number, libpath in srclibpaths:
1545 libpath = os.path.relpath(libpath, root_dir)
1546 cmd = cmd.replace('$$' + name + '$$', libpath)
1548 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1549 if p.returncode != 0:
1550 raise BuildException("Error running prebuild command for %s:%s" %
1551 (app.id, build.versionName), p.output)
1553 # Generate (or update) the ant build file, build.xml...
1554 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1555 parms = ['android', 'update', 'lib-project']
1556 lparms = ['android', 'update', 'project']
1559 parms += ['-t', build.target]
1560 lparms += ['-t', build.target]
1561 if build.androidupdate:
1562 update_dirs = build.androidupdate
1564 update_dirs = ant_subprojects(root_dir) + ['.']
1566 for d in update_dirs:
1567 subdir = os.path.join(root_dir, d)
1569 logging.debug("Updating main project")
1570 cmd = parms + ['-p', d]
1572 logging.debug("Updating subproject %s" % d)
1573 cmd = lparms + ['-p', d]
1574 p = SdkToolsPopen(cmd, cwd=root_dir)
1575 # Check to see whether an error was returned without a proper exit
1576 # code (this is the case for the 'no target set or target invalid'
1578 if p.returncode != 0 or p.output.startswith("Error: "):
1579 raise BuildException("Failed to update project at %s" % d, p.output)
1580 # Clean update dirs via ant
1582 logging.info("Cleaning subproject %s" % d)
1583 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1585 return (root_dir, srclibpaths)
1588 # Extend via globbing the paths from a field and return them as a map from
1589 # original path to resulting paths
1590 def getpaths_map(build_dir, globpaths):
1594 full_path = os.path.join(build_dir, p)
1595 full_path = os.path.normpath(full_path)
1596 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1598 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1602 # Extend via globbing the paths from a field and return them as a set
1603 def getpaths(build_dir, globpaths):
1604 paths_map = getpaths_map(build_dir, globpaths)
1606 for k, v in paths_map.items():
1613 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1619 self.path = os.path.join('stats', 'known_apks.txt')
1621 if os.path.isfile(self.path):
1622 with open(self.path, 'r', encoding='utf8') as f:
1624 t = line.rstrip().split(' ')
1626 self.apks[t[0]] = (t[1], None)
1628 self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1629 self.changed = False
1631 def writeifchanged(self):
1632 if not self.changed:
1635 if not os.path.exists('stats'):
1639 for apk, app in self.apks.items():
1641 line = apk + ' ' + appid
1643 line += ' ' + added.strftime('%Y-%m-%d')
1646 with open(self.path, 'w', encoding='utf8') as f:
1647 for line in sorted(lst, key=natural_key):
1648 f.write(line + '\n')
1650 def recordapk(self, apk, app, default_date=None):
1652 Record an apk (if it's new, otherwise does nothing)
1653 Returns the date it was added as a datetime instance
1655 if apk not in self.apks:
1656 if default_date is None:
1657 default_date = datetime.utcnow()
1658 self.apks[apk] = (app, default_date)
1660 _, added = self.apks[apk]
1663 # Look up information - given the 'apkname', returns (app id, date added/None).
1664 # Or returns None for an unknown apk.
1665 def getapp(self, apkname):
1666 if apkname in self.apks:
1667 return self.apks[apkname]
1670 # Get the most recent 'num' apps added to the repo, as a list of package ids
1671 # with the most recent first.
1672 def getlatest(self, num):
1674 for apk, app in self.apks.items():
1678 if apps[appid] > added:
1682 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1683 lst = [app for app, _ in sortedapps]
1688 def get_file_extension(filename):
1689 """get the normalized file extension, can be blank string but never None"""
1691 return os.path.splitext(filename)[1].lower()[1:]
1694 def isApkAndDebuggable(apkfile, config):
1695 """Returns True if the given file is an APK and is debuggable
1697 :param apkfile: full path to the apk to check"""
1699 if get_file_extension(apkfile) != 'apk':
1702 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1704 if p.returncode != 0:
1705 logging.critical("Failed to get apk manifest information")
1707 for line in p.output.splitlines():
1708 if 'android:debuggable' in line and not line.endswith('0x0'):
1715 self.returncode = None
1719 def SdkToolsPopen(commands, cwd=None, output=True):
1721 if cmd not in config:
1722 config[cmd] = find_sdk_tools_cmd(commands[0])
1723 abscmd = config[cmd]
1725 logging.critical("Could not find '%s' on your system" % cmd)
1728 test_aapt_version(config['aapt'])
1729 return FDroidPopen([abscmd] + commands[1:],
1730 cwd=cwd, output=output)
1733 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1735 Run a command and capture the possibly huge output as bytes.
1737 :param commands: command and argument list like in subprocess.Popen
1738 :param cwd: optionally specifies a working directory
1739 :returns: A PopenResult.
1744 set_FDroidPopen_env()
1747 cwd = os.path.normpath(cwd)
1748 logging.debug("Directory: %s" % cwd)
1749 logging.debug("> %s" % ' '.join(commands))
1751 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1752 result = PopenResult()
1755 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1756 stdout=subprocess.PIPE, stderr=stderr_param)
1757 except OSError as e:
1758 raise BuildException("OSError while trying to execute " +
1759 ' '.join(commands) + ': ' + str(e))
1761 if not stderr_to_stdout and options.verbose:
1762 stderr_queue = Queue()
1763 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1765 while not stderr_reader.eof():
1766 while not stderr_queue.empty():
1767 line = stderr_queue.get()
1768 sys.stderr.buffer.write(line)
1773 stdout_queue = Queue()
1774 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1777 # Check the queue for output (until there is no more to get)
1778 while not stdout_reader.eof():
1779 while not stdout_queue.empty():
1780 line = stdout_queue.get()
1781 if output and options.verbose:
1782 # Output directly to console
1783 sys.stderr.buffer.write(line)
1789 result.returncode = p.wait()
1790 result.output = buf.getvalue()
1795 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1797 Run a command and capture the possibly huge output as a str.
1799 :param commands: command and argument list like in subprocess.Popen
1800 :param cwd: optionally specifies a working directory
1801 :returns: A PopenResult.
1803 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1804 result.output = result.output.decode('utf-8', 'ignore')
1808 gradle_comment = re.compile(r'[ ]*//')
1809 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1810 gradle_line_matches = [
1811 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1812 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1813 re.compile(r'.*\.readLine\(.*'),
1817 def remove_signing_keys(build_dir):
1818 for root, dirs, files in os.walk(build_dir):
1819 if 'build.gradle' in files:
1820 path = os.path.join(root, 'build.gradle')
1822 with open(path, "r", encoding='utf8') as o:
1823 lines = o.readlines()
1829 with open(path, "w", encoding='utf8') as o:
1830 while i < len(lines):
1833 while line.endswith('\\\n'):
1834 line = line.rstrip('\\\n') + lines[i]
1837 if gradle_comment.match(line):
1842 opened += line.count('{')
1843 opened -= line.count('}')
1846 if gradle_signing_configs.match(line):
1851 if any(s.match(line) for s in gradle_line_matches):
1859 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1862 'project.properties',
1864 'default.properties',
1865 'ant.properties', ]:
1866 if propfile in files:
1867 path = os.path.join(root, propfile)
1869 with open(path, "r", encoding='iso-8859-1') as o:
1870 lines = o.readlines()
1874 with open(path, "w", encoding='iso-8859-1') as o:
1876 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1883 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1886 def set_FDroidPopen_env(build=None):
1888 set up the environment variables for the build environment
1890 There is only a weak standard, the variables used by gradle, so also set
1891 up the most commonly used environment variables for SDK and NDK. Also, if
1892 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1894 global env, orig_path
1898 orig_path = env['PATH']
1899 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1900 env[n] = config['sdk_path']
1901 for k, v in config['java_paths'].items():
1902 env['JAVA%s_HOME' % k] = v
1904 missinglocale = True
1905 for k, v in env.items():
1906 if k == 'LANG' and v != 'C':
1907 missinglocale = False
1909 missinglocale = False
1911 env['LANG'] = 'en_US.UTF-8'
1913 if build is not None:
1914 path = build.ndk_path()
1915 paths = orig_path.split(os.pathsep)
1916 if path not in paths:
1917 paths = [path] + paths
1918 env['PATH'] = os.pathsep.join(paths)
1919 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1920 env[n] = build.ndk_path()
1923 def replace_build_vars(cmd, build):
1924 cmd = cmd.replace('$$COMMIT$$', build.commit)
1925 cmd = cmd.replace('$$VERSION$$', build.versionName)
1926 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1930 def replace_config_vars(cmd, build):
1931 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1932 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1933 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1934 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1935 if build is not None:
1936 cmd = replace_build_vars(cmd, build)
1940 def place_srclib(root_dir, number, libpath):
1943 relpath = os.path.relpath(libpath, root_dir)
1944 proppath = os.path.join(root_dir, 'project.properties')
1947 if os.path.isfile(proppath):
1948 with open(proppath, "r", encoding='iso-8859-1') as o:
1949 lines = o.readlines()
1951 with open(proppath, "w", encoding='iso-8859-1') as o:
1954 if line.startswith('android.library.reference.%d=' % number):
1955 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1960 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1963 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1966 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1967 """Verify that two apks are the same
1969 One of the inputs is signed, the other is unsigned. The signature metadata
1970 is transferred from the signed to the unsigned apk, and then jarsigner is
1971 used to verify that the signature from the signed apk is also varlid for
1973 :param signed_apk: Path to a signed apk file
1974 :param unsigned_apk: Path to an unsigned apk file expected to match it
1975 :param tmp_dir: Path to directory for temporary files
1976 :returns: None if the verification is successful, otherwise a string
1977 describing what went wrong.
1979 with ZipFile(signed_apk) as signed_apk_as_zip:
1980 meta_inf_files = ['META-INF/MANIFEST.MF']
1981 for f in signed_apk_as_zip.namelist():
1982 if apk_sigfile.match(f):
1983 meta_inf_files.append(f)
1984 if len(meta_inf_files) < 3:
1985 return "Signature files missing from {0}".format(signed_apk)
1986 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1987 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1988 for meta_inf_file in meta_inf_files:
1989 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1991 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1992 logging.info("...NOT verified - {0}".format(signed_apk))
1993 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1994 logging.info("...successfully verified")
1998 apk_badchars = re.compile('''[/ :;'"]''')
2001 def compare_apks(apk1, apk2, tmp_dir):
2004 Returns None if the apk content is the same (apart from the signing key),
2005 otherwise a string describing what's different, or what went wrong when
2006 trying to do the comparison.
2009 absapk1 = os.path.abspath(apk1)
2010 absapk2 = os.path.abspath(apk2)
2012 # try to find diffoscope in the path, if it hasn't been manually configed
2013 if 'diffoscope' not in config:
2014 tmp = find_command('diffoscope')
2016 config['diffoscope'] = tmp
2017 if 'diffoscope' in config:
2018 htmlfile = absapk1 + '.diffoscope.html'
2019 textfile = absapk1 + '.diffoscope.txt'
2020 if subprocess.call([config['diffoscope'],
2021 '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2022 '--html', htmlfile, '--text', textfile,
2023 absapk1, absapk2]) != 0:
2024 return("Failed to unpack " + apk1)
2026 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
2027 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
2028 for d in [apk1dir, apk2dir]:
2029 if os.path.exists(d):
2032 os.mkdir(os.path.join(d, 'jar-xf'))
2034 if subprocess.call(['jar', 'xf',
2035 os.path.abspath(apk1)],
2036 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2037 return("Failed to unpack " + apk1)
2038 if subprocess.call(['jar', 'xf',
2039 os.path.abspath(apk2)],
2040 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2041 return("Failed to unpack " + apk2)
2043 # try to find apktool in the path, if it hasn't been manually configed
2044 if 'apktool' not in config:
2045 tmp = find_command('apktool')
2047 config['apktool'] = tmp
2048 if 'apktool' in config:
2049 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2051 return("Failed to unpack " + apk1)
2052 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2054 return("Failed to unpack " + apk2)
2056 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2057 lines = p.output.splitlines()
2058 if len(lines) != 1 or 'META-INF' not in lines[0]:
2059 meld = find_command('meld')
2060 if meld is not None:
2061 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2062 return("Unexpected diff output - " + p.output)
2064 # since everything verifies, delete the comparison to keep cruft down
2065 shutil.rmtree(apk1dir)
2066 shutil.rmtree(apk2dir)
2068 # If we get here, it seems like they're the same!
2072 def find_command(command):
2073 '''find the full path of a command, or None if it can't be found in the PATH'''
2076 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2078 fpath, fname = os.path.split(command)
2083 for path in os.environ["PATH"].split(os.pathsep):
2084 path = path.strip('"')
2085 exe_file = os.path.join(path, command)
2086 if is_exe(exe_file):
2093 '''generate a random password for when generating keys'''
2094 h = hashlib.sha256()
2095 h.update(os.urandom(16)) # salt
2096 h.update(socket.getfqdn().encode('utf-8'))
2097 passwd = base64.b64encode(h.digest()).strip()
2098 return passwd.decode('utf-8')
2101 def genkeystore(localconfig):
2102 '''Generate a new key with random passwords and add it to new keystore'''
2103 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2104 keystoredir = os.path.dirname(localconfig['keystore'])
2105 if keystoredir is None or keystoredir == '':
2106 keystoredir = os.path.join(os.getcwd(), keystoredir)
2107 if not os.path.exists(keystoredir):
2108 os.makedirs(keystoredir, mode=0o700)
2110 write_password_file("keystorepass", localconfig['keystorepass'])
2111 write_password_file("keypass", localconfig['keypass'])
2112 p = FDroidPopen([config['keytool'], '-genkey',
2113 '-keystore', localconfig['keystore'],
2114 '-alias', localconfig['repo_keyalias'],
2115 '-keyalg', 'RSA', '-keysize', '4096',
2116 '-sigalg', 'SHA256withRSA',
2117 '-validity', '10000',
2118 '-storepass:file', config['keystorepassfile'],
2119 '-keypass:file', config['keypassfile'],
2120 '-dname', localconfig['keydname']])
2121 # TODO keypass should be sent via stdin
2122 if p.returncode != 0:
2123 raise BuildException("Failed to generate key", p.output)
2124 os.chmod(localconfig['keystore'], 0o0600)
2125 # now show the lovely key that was just generated
2126 p = FDroidPopen([config['keytool'], '-list', '-v',
2127 '-keystore', localconfig['keystore'],
2128 '-alias', localconfig['repo_keyalias'],
2129 '-storepass:file', config['keystorepassfile']])
2130 logging.info(p.output.strip() + '\n\n')
2133 def write_to_config(thisconfig, key, value=None):
2134 '''write a key/value to the local config.py'''
2136 origkey = key + '_orig'
2137 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2138 with open('config.py', 'r', encoding='utf8') as f:
2140 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2141 repl = '\n' + key + ' = "' + value + '"'
2142 data = re.sub(pattern, repl, data)
2143 # if this key is not in the file, append it
2144 if not re.match('\s*' + key + '\s*=\s*"', data):
2146 # make sure the file ends with a carraige return
2147 if not re.match('\n$', data):
2149 with open('config.py', 'w', encoding='utf8') as f:
2153 def parse_xml(path):
2154 return XMLElementTree.parse(path).getroot()
2157 def string_is_integer(string):
2165 def get_per_app_repos():
2166 '''per-app repos are dirs named with the packageName of a single app'''
2168 # Android packageNames are Java packages, they may contain uppercase or
2169 # lowercase letters ('A' through 'Z'), numbers, and underscores
2170 # ('_'). However, individual package name parts may only start with
2171 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2172 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2175 for root, dirs, files in os.walk(os.getcwd()):
2177 print('checking', root, 'for', d)
2178 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2179 # standard parts of an fdroid repo, so never packageNames
2182 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2188 def is_repo_file(filename):
2189 '''Whether the file in a repo is a build product to be delivered to users'''
2190 return os.path.isfile(filename) \
2191 and not filename.endswith('.asc') \
2192 and not filename.endswith('.sig') \
2193 and os.path.basename(filename) not in [
2195 'index_unsigned.jar',