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 # smartcardoptions must be a list since its command line args for Popen
225 if 'smartcardoptions' in config:
226 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
227 elif 'keystore' in config and config['keystore'] == 'NONE':
228 # keystore='NONE' means use smartcard, these are required defaults
229 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
230 'SunPKCS11-OpenSC', '-providerClass',
231 'sun.security.pkcs11.SunPKCS11',
232 '-providerArg', 'opensc-fdroid.cfg']
234 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
235 st = os.stat(config_file)
236 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
237 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
239 fill_config_defaults(config)
241 for k in ["keystorepass", "keypass"]:
243 write_password_file(k)
245 for k in ["repo_description", "archive_description"]:
247 config[k] = clean_description(config[k])
249 if 'serverwebroot' in config:
250 if isinstance(config['serverwebroot'], str):
251 roots = [config['serverwebroot']]
252 elif all(isinstance(item, str) for item in config['serverwebroot']):
253 roots = config['serverwebroot']
255 raise TypeError('only accepts strings, lists, and tuples')
257 for rootstr in roots:
258 # since this is used with rsync, where trailing slashes have
259 # meaning, ensure there is always a trailing slash
260 if rootstr[-1] != '/':
262 rootlist.append(rootstr.replace('//', '/'))
263 config['serverwebroot'] = rootlist
265 if 'servergitmirrors' in config:
266 if isinstance(config['servergitmirrors'], str):
267 roots = [config['servergitmirrors']]
268 elif all(isinstance(item, str) for item in config['servergitmirrors']):
269 roots = config['servergitmirrors']
271 raise TypeError('only accepts strings, lists, and tuples')
272 config['servergitmirrors'] = roots
277 def find_sdk_tools_cmd(cmd):
278 '''find a working path to a tool from the Android SDK'''
281 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
282 # try to find a working path to this command, in all the recent possible paths
283 if 'build_tools' in config:
284 build_tools = os.path.join(config['sdk_path'], 'build-tools')
285 # if 'build_tools' was manually set and exists, check only that one
286 configed_build_tools = os.path.join(build_tools, config['build_tools'])
287 if os.path.exists(configed_build_tools):
288 tooldirs.append(configed_build_tools)
290 # no configed version, so hunt known paths for it
291 for f in sorted(os.listdir(build_tools), reverse=True):
292 if os.path.isdir(os.path.join(build_tools, f)):
293 tooldirs.append(os.path.join(build_tools, f))
294 tooldirs.append(build_tools)
295 sdk_tools = os.path.join(config['sdk_path'], 'tools')
296 if os.path.exists(sdk_tools):
297 tooldirs.append(sdk_tools)
298 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
299 if os.path.exists(sdk_platform_tools):
300 tooldirs.append(sdk_platform_tools)
301 tooldirs.append('/usr/bin')
303 path = os.path.join(d, cmd)
304 if os.path.isfile(path):
306 test_aapt_version(path)
308 # did not find the command, exit with error message
309 ensure_build_tools_exists(config)
312 def test_aapt_version(aapt):
313 '''Check whether the version of aapt is new enough'''
314 output = subprocess.check_output([aapt, 'version'], universal_newlines=True)
315 if output is None or output == '':
316 logging.error(aapt + ' failed to execute!')
318 m = re.match(r'.*v([0-9]+)\.([0-9]+)[.-]?([0-9.-]*)', output)
323 # the Debian package has the version string like "v0.2-23.0.2"
324 if '.' not in bugfix and LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.2166767'):
325 logging.warning(aapt + ' is too old, fdroid requires build-tools-23.0.0 or newer!')
327 logging.warning('Unknown version of aapt, might cause problems: ' + output)
330 def test_sdk_exists(thisconfig):
331 if 'sdk_path' not in thisconfig:
332 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
333 test_aapt_version(thisconfig['aapt'])
336 logging.error("'sdk_path' not set in config.py!")
338 if thisconfig['sdk_path'] == default_config['sdk_path']:
339 logging.error('No Android SDK found!')
340 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
341 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
343 if not os.path.exists(thisconfig['sdk_path']):
344 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
346 if not os.path.isdir(thisconfig['sdk_path']):
347 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
349 for d in ['build-tools', 'platform-tools', 'tools']:
350 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
351 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
352 thisconfig['sdk_path'], d))
357 def ensure_build_tools_exists(thisconfig):
358 if not test_sdk_exists(thisconfig):
360 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
361 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
362 if not os.path.isdir(versioned_build_tools):
363 logging.critical('Android Build Tools path "'
364 + versioned_build_tools + '" does not exist!')
368 def write_password_file(pwtype, password=None):
370 writes out passwords to a protected file instead of passing passwords as
371 command line argments
373 filename = '.fdroid.' + pwtype + '.txt'
374 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
376 os.write(fd, config[pwtype].encode('utf-8'))
378 os.write(fd, password.encode('utf-8'))
380 config[pwtype + 'file'] = filename
383 def get_local_metadata_files():
384 '''get any metadata files local to an app's source repo
386 This tries to ignore anything that does not count as app metdata,
387 including emacs cruft ending in ~ and the .fdroid.key*pass.txt files.
390 return glob.glob('.fdroid.[a-jl-z]*[a-rt-z]')
393 def read_pkg_args(args, allow_vercodes=False):
395 Given the arguments in the form of multiple appid:[vc] strings, this returns
396 a dictionary with the set of vercodes specified for each package.
404 if allow_vercodes and ':' in p:
405 package, vercode = p.split(':')
407 package, vercode = p, None
408 if package not in vercodes:
409 vercodes[package] = [vercode] if vercode else []
411 elif vercode and vercode not in vercodes[package]:
412 vercodes[package] += [vercode] if vercode else []
417 def read_app_args(args, allapps, allow_vercodes=False):
419 On top of what read_pkg_args does, this returns the whole app metadata, but
420 limiting the builds list to the builds matching the vercodes specified.
423 vercodes = read_pkg_args(args, allow_vercodes)
429 for appid, app in allapps.items():
430 if appid in vercodes:
433 if len(apps) != len(vercodes):
436 logging.critical("No such package: %s" % p)
437 raise FDroidException("Found invalid app ids in arguments")
439 raise FDroidException("No packages specified")
442 for appid, app in apps.items():
446 app.builds = [b for b in app.builds if b.versionCode in vc]
447 if len(app.builds) != len(vercodes[appid]):
449 allvcs = [b.versionCode for b in app.builds]
450 for v in vercodes[appid]:
452 logging.critical("No such vercode %s for app %s" % (v, appid))
455 raise FDroidException("Found invalid vercodes for some apps")
460 def get_extension(filename):
461 base, ext = os.path.splitext(filename)
464 return base, ext.lower()[1:]
467 def has_extension(filename, ext):
468 _, f_ext = get_extension(filename)
472 publish_name_regex = re.compile(r"^(.+)_([0-9]+)\.(apk|zip)$")
475 def clean_description(description):
476 'Remove unneeded newlines and spaces from a block of description text'
478 # this is split up by paragraph to make removing the newlines easier
479 for paragraph in re.split(r'\n\n', description):
480 paragraph = re.sub('\r', '', paragraph)
481 paragraph = re.sub('\n', ' ', paragraph)
482 paragraph = re.sub(' {2,}', ' ', paragraph)
483 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
484 returnstring += paragraph + '\n\n'
485 return returnstring.rstrip('\n')
488 def publishednameinfo(filename):
489 filename = os.path.basename(filename)
490 m = publish_name_regex.match(filename)
492 result = (m.group(1), m.group(2))
493 except AttributeError:
494 raise FDroidException("Invalid name for published file: %s" % filename)
498 def get_release_filename(app, build):
500 return "%s_%s.%s" % (app.id, build.versionCode, get_file_extension(build.output))
502 return "%s_%s.apk" % (app.id, build.versionCode)
505 def getsrcname(app, build):
506 return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
518 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
521 def get_build_dir(app):
522 '''get the dir that this app will be built in'''
524 if app.RepoType == 'srclib':
525 return os.path.join('build', 'srclib', app.Repo)
527 return os.path.join('build', app.id)
531 '''checkout code from VCS and return instance of vcs and the build dir'''
532 build_dir = get_build_dir(app)
534 # Set up vcs interface and make sure we have the latest code...
535 logging.debug("Getting {0} vcs interface for {1}"
536 .format(app.RepoType, app.Repo))
537 if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
541 vcs = getvcs(app.RepoType, remote, build_dir)
543 return vcs, build_dir
546 def getvcs(vcstype, remote, local):
548 return vcs_git(remote, local)
549 if vcstype == 'git-svn':
550 return vcs_gitsvn(remote, local)
552 return vcs_hg(remote, local)
554 return vcs_bzr(remote, local)
555 if vcstype == 'srclib':
556 if local != os.path.join('build', 'srclib', remote):
557 raise VCSException("Error: srclib paths are hard-coded!")
558 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
560 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
561 raise VCSException("Invalid vcs type " + vcstype)
564 def getsrclibvcs(name):
565 if name not in fdroidserver.metadata.srclibs:
566 raise VCSException("Missing srclib " + name)
567 return fdroidserver.metadata.srclibs[name]['Repo Type']
572 def __init__(self, remote, local):
574 # svn, git-svn and bzr may require auth
576 if self.repotype() in ('git-svn', 'bzr'):
578 if self.repotype == 'git-svn':
579 raise VCSException("Authentication is not supported for git-svn")
580 self.username, remote = remote.split('@')
581 if ':' not in self.username:
582 raise VCSException("Password required with username")
583 self.username, self.password = self.username.split(':')
587 self.clone_failed = False
588 self.refreshed = False
594 # Take the local repository to a clean version of the given revision, which
595 # is specificed in the VCS's native format. Beforehand, the repository can
596 # be dirty, or even non-existent. If the repository does already exist
597 # locally, it will be updated from the origin, but only once in the
598 # lifetime of the vcs object.
599 # None is acceptable for 'rev' if you know you are cloning a clean copy of
600 # the repo - otherwise it must specify a valid revision.
601 def gotorevision(self, rev, refresh=True):
603 if self.clone_failed:
604 raise VCSException("Downloading the repository already failed once, not trying again.")
606 # The .fdroidvcs-id file for a repo tells us what VCS type
607 # and remote that directory was created from, allowing us to drop it
608 # automatically if either of those things changes.
609 fdpath = os.path.join(self.local, '..',
610 '.fdroidvcs-' + os.path.basename(self.local))
611 fdpath = os.path.normpath(fdpath)
612 cdata = self.repotype() + ' ' + self.remote
615 if os.path.exists(self.local):
616 if os.path.exists(fdpath):
617 with open(fdpath, 'r') as f:
618 fsdata = f.read().strip()
623 logging.info("Repository details for %s changed - deleting" % (
627 logging.info("Repository details for %s missing - deleting" % (
630 shutil.rmtree(self.local)
634 self.refreshed = True
637 self.gotorevisionx(rev)
638 except FDroidException as e:
641 # If necessary, write the .fdroidvcs file.
642 if writeback and not self.clone_failed:
643 os.makedirs(os.path.dirname(fdpath), exist_ok=True)
644 with open(fdpath, 'w+') as f:
650 # Derived classes need to implement this. It's called once basic checking
651 # has been performend.
652 def gotorevisionx(self, rev):
653 raise VCSException("This VCS type doesn't define gotorevisionx")
655 # Initialise and update submodules
656 def initsubmodules(self):
657 raise VCSException('Submodules not supported for this vcs type')
659 # Get a list of all known tags
661 if not self._gettags:
662 raise VCSException('gettags not supported for this vcs type')
664 for tag in self._gettags():
665 if re.match('[-A-Za-z0-9_. /]+$', tag):
669 # Get a list of all the known tags, sorted from newest to oldest
670 def latesttags(self):
671 raise VCSException('latesttags not supported for this vcs type')
673 # Get current commit reference (hash, revision, etc)
675 raise VCSException('getref not supported for this vcs type')
677 # Returns the srclib (name, path) used in setting up the current
688 # If the local directory exists, but is somehow not a git repository, git
689 # will traverse up the directory tree until it finds one that is (i.e.
690 # fdroidserver) and then we'll proceed to destroy it! This is called as
693 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
694 result = p.output.rstrip()
695 if not result.endswith(self.local):
696 raise VCSException('Repository mismatch')
698 def gotorevisionx(self, rev):
699 if not os.path.exists(self.local):
701 p = FDroidPopen(['git', 'clone', self.remote, self.local])
702 if p.returncode != 0:
703 self.clone_failed = True
704 raise VCSException("Git clone failed", p.output)
708 # Discard any working tree changes
709 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
710 'git', 'reset', '--hard'], cwd=self.local, output=False)
711 if p.returncode != 0:
712 raise VCSException("Git reset failed", p.output)
713 # Remove untracked files now, in case they're tracked in the target
714 # revision (it happens!)
715 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
716 'git', 'clean', '-dffx'], cwd=self.local, output=False)
717 if p.returncode != 0:
718 raise VCSException("Git clean failed", p.output)
719 if not self.refreshed:
720 # Get latest commits and tags from remote
721 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
722 if p.returncode != 0:
723 raise VCSException("Git fetch failed", p.output)
724 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
725 if p.returncode != 0:
726 raise VCSException("Git fetch failed", p.output)
727 # Recreate origin/HEAD as git clone would do it, in case it disappeared
728 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
729 if p.returncode != 0:
730 lines = p.output.splitlines()
731 if 'Multiple remote HEAD branches' not in lines[0]:
732 raise VCSException("Git remote set-head failed", p.output)
733 branch = lines[1].split(' ')[-1]
734 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
735 if p2.returncode != 0:
736 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
737 self.refreshed = True
738 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
739 # a github repo. Most of the time this is the same as origin/master.
740 rev = rev or 'origin/HEAD'
741 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
742 if p.returncode != 0:
743 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
744 # Get rid of any uncontrolled files left behind
745 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
746 if p.returncode != 0:
747 raise VCSException("Git clean failed", p.output)
749 def initsubmodules(self):
751 submfile = os.path.join(self.local, '.gitmodules')
752 if not os.path.isfile(submfile):
753 raise VCSException("No git submodules available")
755 # fix submodules not accessible without an account and public key auth
756 with open(submfile, 'r') as f:
757 lines = f.readlines()
758 with open(submfile, 'w') as f:
760 if 'git@github.com' in line:
761 line = line.replace('git@github.com:', 'https://github.com/')
762 if 'git@gitlab.com' in line:
763 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
766 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
767 if p.returncode != 0:
768 raise VCSException("Git submodule sync failed", p.output)
769 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
770 if p.returncode != 0:
771 raise VCSException("Git submodule update failed", p.output)
775 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
776 return p.output.splitlines()
778 tag_format = re.compile(r'tag: ([^),]*)')
780 def latesttags(self):
782 p = FDroidPopen(['git', 'log', '--tags',
783 '--simplify-by-decoration', '--pretty=format:%d'],
784 cwd=self.local, output=False)
786 for line in p.output.splitlines():
787 for tag in self.tag_format.findall(line):
792 class vcs_gitsvn(vcs):
797 # If the local directory exists, but is somehow not a git repository, git
798 # will traverse up the directory tree until it finds one that is (i.e.
799 # fdroidserver) and then we'll proceed to destory it! This is called as
802 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
803 result = p.output.rstrip()
804 if not result.endswith(self.local):
805 raise VCSException('Repository mismatch')
807 def gotorevisionx(self, rev):
808 if not os.path.exists(self.local):
810 gitsvn_args = ['git', 'svn', 'clone']
811 if ';' in self.remote:
812 remote_split = self.remote.split(';')
813 for i in remote_split[1:]:
814 if i.startswith('trunk='):
815 gitsvn_args.extend(['-T', i[6:]])
816 elif i.startswith('tags='):
817 gitsvn_args.extend(['-t', i[5:]])
818 elif i.startswith('branches='):
819 gitsvn_args.extend(['-b', i[9:]])
820 gitsvn_args.extend([remote_split[0], self.local])
821 p = FDroidPopen(gitsvn_args, output=False)
822 if p.returncode != 0:
823 self.clone_failed = True
824 raise VCSException("Git svn clone failed", p.output)
826 gitsvn_args.extend([self.remote, 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)
834 # Discard any working tree changes
835 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
836 if p.returncode != 0:
837 raise VCSException("Git reset failed", p.output)
838 # Remove untracked files now, in case they're tracked in the target
839 # revision (it happens!)
840 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
841 if p.returncode != 0:
842 raise VCSException("Git clean failed", p.output)
843 if not self.refreshed:
844 # Get new commits, branches and tags from repo
845 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
846 if p.returncode != 0:
847 raise VCSException("Git svn fetch failed")
848 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
849 if p.returncode != 0:
850 raise VCSException("Git svn rebase failed", p.output)
851 self.refreshed = True
853 rev = rev or 'master'
855 nospaces_rev = rev.replace(' ', '%20')
856 # Try finding a svn tag
857 for treeish in ['origin/', '']:
858 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
859 if p.returncode == 0:
861 if p.returncode != 0:
862 # No tag found, normal svn rev translation
863 # Translate svn rev into git format
864 rev_split = rev.split('/')
867 for treeish in ['origin/', '']:
868 if len(rev_split) > 1:
869 treeish += rev_split[0]
870 svn_rev = rev_split[1]
873 # if no branch is specified, then assume trunk (i.e. 'master' branch):
877 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
879 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
880 git_rev = p.output.rstrip()
882 if p.returncode == 0 and git_rev:
885 if p.returncode != 0 or not git_rev:
886 # Try a plain git checkout as a last resort
887 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
888 if p.returncode != 0:
889 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
891 # Check out the git rev equivalent to the svn rev
892 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
893 if p.returncode != 0:
894 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
896 # Get rid of any uncontrolled files left behind
897 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
898 if p.returncode != 0:
899 raise VCSException("Git clean failed", p.output)
903 for treeish in ['origin/', '']:
904 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
910 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
911 if p.returncode != 0:
913 return p.output.strip()
921 def gotorevisionx(self, rev):
922 if not os.path.exists(self.local):
923 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
924 if p.returncode != 0:
925 self.clone_failed = True
926 raise VCSException("Hg clone failed", p.output)
928 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
929 if p.returncode != 0:
930 raise VCSException("Hg status failed", p.output)
931 for line in p.output.splitlines():
932 if not line.startswith('? '):
933 raise VCSException("Unexpected output from hg status -uS: " + line)
934 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
935 if not self.refreshed:
936 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
937 if p.returncode != 0:
938 raise VCSException("Hg pull failed", p.output)
939 self.refreshed = True
941 rev = rev or 'default'
944 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
945 if p.returncode != 0:
946 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
947 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
948 # Also delete untracked files, we have to enable purge extension for that:
949 if "'purge' is provided by the following extension" in p.output:
950 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
951 myfile.write("\n[extensions]\nhgext.purge=\n")
952 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
953 if p.returncode != 0:
954 raise VCSException("HG purge failed", p.output)
955 elif p.returncode != 0:
956 raise VCSException("HG purge failed", p.output)
959 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
960 return p.output.splitlines()[1:]
968 def gotorevisionx(self, rev):
969 if not os.path.exists(self.local):
970 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
971 if p.returncode != 0:
972 self.clone_failed = True
973 raise VCSException("Bzr branch failed", p.output)
975 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
976 if p.returncode != 0:
977 raise VCSException("Bzr revert failed", p.output)
978 if not self.refreshed:
979 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
980 if p.returncode != 0:
981 raise VCSException("Bzr update failed", p.output)
982 self.refreshed = True
984 revargs = list(['-r', rev] if rev else [])
985 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
986 if p.returncode != 0:
987 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
990 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
991 return [tag.split(' ')[0].strip() for tag in
992 p.output.splitlines()]
995 def unescape_string(string):
998 if string[0] == '"' and string[-1] == '"':
1001 return string.replace("\\'", "'")
1004 def retrieve_string(app_dir, string, xmlfiles=None):
1006 if not string.startswith('@string/'):
1007 return unescape_string(string)
1009 if xmlfiles is None:
1012 os.path.join(app_dir, 'res'),
1013 os.path.join(app_dir, 'src', 'main', 'res'),
1015 for r, d, f in os.walk(res_dir):
1016 if os.path.basename(r) == 'values':
1017 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1019 name = string[len('@string/'):]
1021 def element_content(element):
1022 if element.text is None:
1024 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1025 return s.decode('utf-8').strip()
1027 for path in xmlfiles:
1028 if not os.path.isfile(path):
1030 xml = parse_xml(path)
1031 element = xml.find('string[@name="' + name + '"]')
1032 if element is not None:
1033 content = element_content(element)
1034 return retrieve_string(app_dir, content, xmlfiles)
1039 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1040 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1043 def manifest_paths(app_dir, flavours):
1044 '''Return list of existing files that will be used to find the highest vercode'''
1046 possible_manifests = \
1047 [os.path.join(app_dir, 'AndroidManifest.xml'),
1048 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1049 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1050 os.path.join(app_dir, 'build.gradle')]
1052 for flavour in flavours:
1053 if flavour == 'yes':
1055 possible_manifests.append(
1056 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1058 return [path for path in possible_manifests if os.path.isfile(path)]
1061 def fetch_real_name(app_dir, flavours):
1062 '''Retrieve the package name. Returns the name, or None if not found.'''
1063 for path in manifest_paths(app_dir, flavours):
1064 if not has_extension(path, 'xml') or not os.path.isfile(path):
1066 logging.debug("fetch_real_name: Checking manifest at " + path)
1067 xml = parse_xml(path)
1068 app = xml.find('application')
1071 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1073 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1074 result = retrieve_string_singleline(app_dir, label)
1076 result = result.strip()
1081 def get_library_references(root_dir):
1083 proppath = os.path.join(root_dir, 'project.properties')
1084 if not os.path.isfile(proppath):
1086 with open(proppath, 'r', encoding='iso-8859-1') as f:
1088 if not line.startswith('android.library.reference.'):
1090 path = line.split('=')[1].strip()
1091 relpath = os.path.join(root_dir, path)
1092 if not os.path.isdir(relpath):
1094 logging.debug("Found subproject at %s" % path)
1095 libraries.append(path)
1099 def ant_subprojects(root_dir):
1100 subprojects = get_library_references(root_dir)
1101 for subpath in subprojects:
1102 subrelpath = os.path.join(root_dir, subpath)
1103 for p in get_library_references(subrelpath):
1104 relp = os.path.normpath(os.path.join(subpath, p))
1105 if relp not in subprojects:
1106 subprojects.insert(0, relp)
1110 def remove_debuggable_flags(root_dir):
1111 # Remove forced debuggable flags
1112 logging.debug("Removing debuggable flags from %s" % root_dir)
1113 for root, dirs, files in os.walk(root_dir):
1114 if 'AndroidManifest.xml' in files:
1115 regsub_file(r'android:debuggable="[^"]*"',
1117 os.path.join(root, 'AndroidManifest.xml'))
1120 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1121 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1122 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1125 def app_matches_packagename(app, package):
1128 appid = app.UpdateCheckName or app.id
1129 if appid is None or appid == "Ignore":
1131 return appid == package
1134 def parse_androidmanifests(paths, app):
1136 Extract some information from the AndroidManifest.xml at the given path.
1137 Returns (version, vercode, package), any or all of which might be None.
1138 All values returned are strings.
1141 ignoreversions = app.UpdateCheckIgnore
1142 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1145 return (None, None, None)
1153 if not os.path.isfile(path):
1156 logging.debug("Parsing manifest at {0}".format(path))
1157 gradle = has_extension(path, 'gradle')
1163 with open(path, 'r') as f:
1165 if gradle_comment.match(line):
1167 # Grab first occurence of each to avoid running into
1168 # alternative flavours and builds.
1170 matches = psearch_g(line)
1172 s = matches.group(2)
1173 if app_matches_packagename(app, s):
1176 matches = vnsearch_g(line)
1178 version = matches.group(2)
1180 matches = vcsearch_g(line)
1182 vercode = matches.group(1)
1185 xml = parse_xml(path)
1186 if "package" in xml.attrib:
1187 s = xml.attrib["package"]
1188 if app_matches_packagename(app, s):
1190 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1191 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1192 base_dir = os.path.dirname(path)
1193 version = retrieve_string_singleline(base_dir, version)
1194 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1195 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1196 if string_is_integer(a):
1199 logging.warning("Problem with xml at {0}".format(path))
1201 # Remember package name, may be defined separately from version+vercode
1203 package = max_package
1205 logging.debug("..got package={0}, version={1}, vercode={2}"
1206 .format(package, version, vercode))
1208 # Always grab the package name and version name in case they are not
1209 # together with the highest version code
1210 if max_package is None and package is not None:
1211 max_package = package
1212 if max_version is None and version is not None:
1213 max_version = version
1215 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1216 if not ignoresearch or not ignoresearch(version):
1217 if version is not None:
1218 max_version = version
1219 if vercode is not None:
1220 max_vercode = vercode
1221 if package is not None:
1222 max_package = package
1224 max_version = "Ignore"
1226 if max_version is None:
1227 max_version = "Unknown"
1229 if max_package and not is_valid_package_name(max_package):
1230 raise FDroidException("Invalid package name {0}".format(max_package))
1232 return (max_version, max_vercode, max_package)
1235 def is_valid_package_name(name):
1236 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1239 class FDroidException(Exception):
1241 def __init__(self, value, detail=None):
1243 self.detail = detail
1245 def shortened_detail(self):
1246 if len(self.detail) < 16000:
1248 return '[...]\n' + self.detail[-16000:]
1250 def get_wikitext(self):
1251 ret = repr(self.value) + "\n"
1254 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1260 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1264 class VCSException(FDroidException):
1268 class BuildException(FDroidException):
1272 # Get the specified source library.
1273 # Returns the path to it. Normally this is the path to be used when referencing
1274 # it, which may be a subdirectory of the actual project. If you want the base
1275 # directory of the project, pass 'basepath=True'.
1276 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1277 raw=False, prepare=True, preponly=False, refresh=True,
1286 name, ref = spec.split('@')
1288 number, name = name.split(':', 1)
1290 name, subdir = name.split('/', 1)
1292 if name not in fdroidserver.metadata.srclibs:
1293 raise VCSException('srclib ' + name + ' not found.')
1295 srclib = fdroidserver.metadata.srclibs[name]
1297 sdir = os.path.join(srclib_dir, name)
1300 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1301 vcs.srclib = (name, number, sdir)
1303 vcs.gotorevision(ref, refresh)
1310 libdir = os.path.join(sdir, subdir)
1311 elif srclib["Subdir"]:
1312 for subdir in srclib["Subdir"]:
1313 libdir_candidate = os.path.join(sdir, subdir)
1314 if os.path.exists(libdir_candidate):
1315 libdir = libdir_candidate
1321 remove_signing_keys(sdir)
1322 remove_debuggable_flags(sdir)
1326 if srclib["Prepare"]:
1327 cmd = replace_config_vars(srclib["Prepare"], build)
1329 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1330 if p.returncode != 0:
1331 raise BuildException("Error running prepare command for srclib %s"
1337 return (name, number, libdir)
1340 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1343 # Prepare the source code for a particular build
1344 # 'vcs' - the appropriate vcs object for the application
1345 # 'app' - the application details from the metadata
1346 # 'build' - the build details from the metadata
1347 # 'build_dir' - the path to the build directory, usually
1349 # 'srclib_dir' - the path to the source libraries directory, usually
1351 # 'extlib_dir' - the path to the external libraries directory, usually
1353 # Returns the (root, srclibpaths) where:
1354 # 'root' is the root directory, which may be the same as 'build_dir' or may
1355 # be a subdirectory of it.
1356 # 'srclibpaths' is information on the srclibs being used
1357 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1359 # Optionally, the actual app source can be in a subdirectory
1361 root_dir = os.path.join(build_dir, build.subdir)
1363 root_dir = build_dir
1365 # Get a working copy of the right revision
1366 logging.info("Getting source for revision " + build.commit)
1367 vcs.gotorevision(build.commit, refresh)
1369 # Initialise submodules if required
1370 if build.submodules:
1371 logging.info("Initialising submodules")
1372 vcs.initsubmodules()
1374 # Check that a subdir (if we're using one) exists. This has to happen
1375 # after the checkout, since it might not exist elsewhere
1376 if not os.path.exists(root_dir):
1377 raise BuildException('Missing subdir ' + root_dir)
1379 # Run an init command if one is required
1381 cmd = replace_config_vars(build.init, build)
1382 logging.info("Running 'init' commands in %s" % root_dir)
1384 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1385 if p.returncode != 0:
1386 raise BuildException("Error running init command for %s:%s" %
1387 (app.id, build.versionName), p.output)
1389 # Apply patches if any
1391 logging.info("Applying patches")
1392 for patch in build.patch:
1393 patch = patch.strip()
1394 logging.info("Applying " + patch)
1395 patch_path = os.path.join('metadata', app.id, patch)
1396 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1397 if p.returncode != 0:
1398 raise BuildException("Failed to apply patch %s" % patch_path)
1400 # Get required source libraries
1403 logging.info("Collecting source libraries")
1404 for lib in build.srclibs:
1405 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1406 refresh=refresh, build=build))
1408 for name, number, libpath in srclibpaths:
1409 place_srclib(root_dir, int(number) if number else None, libpath)
1411 basesrclib = vcs.getsrclib()
1412 # If one was used for the main source, add that too.
1414 srclibpaths.append(basesrclib)
1416 # Update the local.properties file
1417 localprops = [os.path.join(build_dir, 'local.properties')]
1419 parts = build.subdir.split(os.sep)
1422 cur = os.path.join(cur, d)
1423 localprops += [os.path.join(cur, 'local.properties')]
1424 for path in localprops:
1426 if os.path.isfile(path):
1427 logging.info("Updating local.properties file at %s" % path)
1428 with open(path, 'r', encoding='iso-8859-1') as f:
1432 logging.info("Creating local.properties file at %s" % path)
1433 # Fix old-fashioned 'sdk-location' by copying
1434 # from sdk.dir, if necessary
1436 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1437 re.S | re.M).group(1)
1438 props += "sdk-location=%s\n" % sdkloc
1440 props += "sdk.dir=%s\n" % config['sdk_path']
1441 props += "sdk-location=%s\n" % config['sdk_path']
1442 ndk_path = build.ndk_path()
1443 # if for any reason the path isn't valid or the directory
1444 # doesn't exist, some versions of Gradle will error with a
1445 # cryptic message (even if the NDK is not even necessary).
1446 # https://gitlab.com/fdroid/fdroidserver/issues/171
1447 if ndk_path and os.path.exists(ndk_path):
1449 props += "ndk.dir=%s\n" % ndk_path
1450 props += "ndk-location=%s\n" % ndk_path
1451 # Add java.encoding if necessary
1453 props += "java.encoding=%s\n" % build.encoding
1454 with open(path, 'w', encoding='iso-8859-1') as f:
1458 if build.build_method() == 'gradle':
1459 flavours = build.gradle
1462 n = build.target.split('-')[1]
1463 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1464 r'compileSdkVersion %s' % n,
1465 os.path.join(root_dir, 'build.gradle'))
1467 # Remove forced debuggable flags
1468 remove_debuggable_flags(root_dir)
1470 # Insert version code and number into the manifest if necessary
1471 if build.forceversion:
1472 logging.info("Changing the version name")
1473 for path in manifest_paths(root_dir, flavours):
1474 if not os.path.isfile(path):
1476 if has_extension(path, 'xml'):
1477 regsub_file(r'android:versionName="[^"]*"',
1478 r'android:versionName="%s"' % build.versionName,
1480 elif has_extension(path, 'gradle'):
1481 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1482 r"""\1versionName '%s'""" % build.versionName,
1485 if build.forcevercode:
1486 logging.info("Changing the version code")
1487 for path in manifest_paths(root_dir, flavours):
1488 if not os.path.isfile(path):
1490 if has_extension(path, 'xml'):
1491 regsub_file(r'android:versionCode="[^"]*"',
1492 r'android:versionCode="%s"' % build.versionCode,
1494 elif has_extension(path, 'gradle'):
1495 regsub_file(r'versionCode[ =]+[0-9]+',
1496 r'versionCode %s' % build.versionCode,
1499 # Delete unwanted files
1501 logging.info("Removing specified files")
1502 for part in getpaths(build_dir, build.rm):
1503 dest = os.path.join(build_dir, part)
1504 logging.info("Removing {0}".format(part))
1505 if os.path.lexists(dest):
1506 if os.path.islink(dest):
1507 FDroidPopen(['unlink', dest], output=False)
1509 FDroidPopen(['rm', '-rf', dest], output=False)
1511 logging.info("...but it didn't exist")
1513 remove_signing_keys(build_dir)
1515 # Add required external libraries
1517 logging.info("Collecting prebuilt libraries")
1518 libsdir = os.path.join(root_dir, 'libs')
1519 if not os.path.exists(libsdir):
1521 for lib in build.extlibs:
1523 logging.info("...installing extlib {0}".format(lib))
1524 libf = os.path.basename(lib)
1525 libsrc = os.path.join(extlib_dir, lib)
1526 if not os.path.exists(libsrc):
1527 raise BuildException("Missing extlib file {0}".format(libsrc))
1528 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1530 # Run a pre-build command if one is required
1532 logging.info("Running 'prebuild' commands in %s" % root_dir)
1534 cmd = replace_config_vars(build.prebuild, build)
1536 # Substitute source library paths into prebuild commands
1537 for name, number, libpath in srclibpaths:
1538 libpath = os.path.relpath(libpath, root_dir)
1539 cmd = cmd.replace('$$' + name + '$$', libpath)
1541 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1542 if p.returncode != 0:
1543 raise BuildException("Error running prebuild command for %s:%s" %
1544 (app.id, build.versionName), p.output)
1546 # Generate (or update) the ant build file, build.xml...
1547 if build.build_method() == 'ant' and build.androidupdate != ['no']:
1548 parms = ['android', 'update', 'lib-project']
1549 lparms = ['android', 'update', 'project']
1552 parms += ['-t', build.target]
1553 lparms += ['-t', build.target]
1554 if build.androidupdate:
1555 update_dirs = build.androidupdate
1557 update_dirs = ant_subprojects(root_dir) + ['.']
1559 for d in update_dirs:
1560 subdir = os.path.join(root_dir, d)
1562 logging.debug("Updating main project")
1563 cmd = parms + ['-p', d]
1565 logging.debug("Updating subproject %s" % d)
1566 cmd = lparms + ['-p', d]
1567 p = SdkToolsPopen(cmd, cwd=root_dir)
1568 # Check to see whether an error was returned without a proper exit
1569 # code (this is the case for the 'no target set or target invalid'
1571 if p.returncode != 0 or p.output.startswith("Error: "):
1572 raise BuildException("Failed to update project at %s" % d, p.output)
1573 # Clean update dirs via ant
1575 logging.info("Cleaning subproject %s" % d)
1576 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1578 return (root_dir, srclibpaths)
1581 # Extend via globbing the paths from a field and return them as a map from
1582 # original path to resulting paths
1583 def getpaths_map(build_dir, globpaths):
1587 full_path = os.path.join(build_dir, p)
1588 full_path = os.path.normpath(full_path)
1589 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1591 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1595 # Extend via globbing the paths from a field and return them as a set
1596 def getpaths(build_dir, globpaths):
1597 paths_map = getpaths_map(build_dir, globpaths)
1599 for k, v in paths_map.items():
1606 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1612 self.path = os.path.join('stats', 'known_apks.txt')
1614 if os.path.isfile(self.path):
1615 with open(self.path, 'r', encoding='utf8') as f:
1617 t = line.rstrip().split(' ')
1619 self.apks[t[0]] = (t[1], None)
1621 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1622 self.changed = False
1624 def writeifchanged(self):
1625 if not self.changed:
1628 if not os.path.exists('stats'):
1632 for apk, app in self.apks.items():
1634 line = apk + ' ' + appid
1636 line += ' ' + time.strftime('%Y-%m-%d', added)
1639 with open(self.path, 'w', encoding='utf8') as f:
1640 for line in sorted(lst, key=natural_key):
1641 f.write(line + '\n')
1643 # Record an apk (if it's new, otherwise does nothing)
1644 # Returns the date it was added.
1645 def recordapk(self, apk, app, default_date=None):
1646 if apk not in self.apks:
1647 if default_date is None:
1648 default_date = time.gmtime(time.time())
1649 self.apks[apk] = (app, default_date)
1651 _, added = self.apks[apk]
1654 # Look up information - given the 'apkname', returns (app id, date added/None).
1655 # Or returns None for an unknown apk.
1656 def getapp(self, apkname):
1657 if apkname in self.apks:
1658 return self.apks[apkname]
1661 # Get the most recent 'num' apps added to the repo, as a list of package ids
1662 # with the most recent first.
1663 def getlatest(self, num):
1665 for apk, app in self.apks.items():
1669 if apps[appid] > added:
1673 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1674 lst = [app for app, _ in sortedapps]
1679 def get_file_extension(filename):
1680 """get the normalized file extension, can be blank string but never None"""
1682 return os.path.splitext(filename)[1].lower()[1:]
1685 def isApkAndDebuggable(apkfile, config):
1686 """Returns True if the given file is an APK and is debuggable
1688 :param apkfile: full path to the apk to check"""
1690 if get_file_extension(apkfile) != 'apk':
1693 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1695 if p.returncode != 0:
1696 logging.critical("Failed to get apk manifest information")
1698 for line in p.output.splitlines():
1699 if 'android:debuggable' in line and not line.endswith('0x0'):
1706 self.returncode = None
1710 def SdkToolsPopen(commands, cwd=None, output=True):
1712 if cmd not in config:
1713 config[cmd] = find_sdk_tools_cmd(commands[0])
1714 abscmd = config[cmd]
1716 logging.critical("Could not find '%s' on your system" % cmd)
1719 test_aapt_version(config['aapt'])
1720 return FDroidPopen([abscmd] + commands[1:],
1721 cwd=cwd, output=output)
1724 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1726 Run a command and capture the possibly huge output as bytes.
1728 :param commands: command and argument list like in subprocess.Popen
1729 :param cwd: optionally specifies a working directory
1730 :returns: A PopenResult.
1735 set_FDroidPopen_env()
1738 cwd = os.path.normpath(cwd)
1739 logging.debug("Directory: %s" % cwd)
1740 logging.debug("> %s" % ' '.join(commands))
1742 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1743 result = PopenResult()
1746 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1747 stdout=subprocess.PIPE, stderr=stderr_param)
1748 except OSError as e:
1749 raise BuildException("OSError while trying to execute " +
1750 ' '.join(commands) + ': ' + str(e))
1752 if not stderr_to_stdout and options.verbose:
1753 stderr_queue = Queue()
1754 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1756 while not stderr_reader.eof():
1757 while not stderr_queue.empty():
1758 line = stderr_queue.get()
1759 sys.stderr.buffer.write(line)
1764 stdout_queue = Queue()
1765 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1768 # Check the queue for output (until there is no more to get)
1769 while not stdout_reader.eof():
1770 while not stdout_queue.empty():
1771 line = stdout_queue.get()
1772 if output and options.verbose:
1773 # Output directly to console
1774 sys.stderr.buffer.write(line)
1780 result.returncode = p.wait()
1781 result.output = buf.getvalue()
1786 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1788 Run a command and capture the possibly huge output as a str.
1790 :param commands: command and argument list like in subprocess.Popen
1791 :param cwd: optionally specifies a working directory
1792 :returns: A PopenResult.
1794 result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1795 result.output = result.output.decode('utf-8')
1799 gradle_comment = re.compile(r'[ ]*//')
1800 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1801 gradle_line_matches = [
1802 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1803 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1804 re.compile(r'.*\.readLine\(.*'),
1808 def remove_signing_keys(build_dir):
1809 for root, dirs, files in os.walk(build_dir):
1810 if 'build.gradle' in files:
1811 path = os.path.join(root, 'build.gradle')
1813 with open(path, "r", encoding='utf8') as o:
1814 lines = o.readlines()
1820 with open(path, "w", encoding='utf8') as o:
1821 while i < len(lines):
1824 while line.endswith('\\\n'):
1825 line = line.rstrip('\\\n') + lines[i]
1828 if gradle_comment.match(line):
1833 opened += line.count('{')
1834 opened -= line.count('}')
1837 if gradle_signing_configs.match(line):
1842 if any(s.match(line) for s in gradle_line_matches):
1850 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1853 'project.properties',
1855 'default.properties',
1856 'ant.properties', ]:
1857 if propfile in files:
1858 path = os.path.join(root, propfile)
1860 with open(path, "r", encoding='iso-8859-1') as o:
1861 lines = o.readlines()
1865 with open(path, "w", encoding='iso-8859-1') as o:
1867 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1874 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1877 def set_FDroidPopen_env(build=None):
1879 set up the environment variables for the build environment
1881 There is only a weak standard, the variables used by gradle, so also set
1882 up the most commonly used environment variables for SDK and NDK. Also, if
1883 there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1885 global env, orig_path
1889 orig_path = env['PATH']
1890 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1891 env[n] = config['sdk_path']
1892 for k, v in config['java_paths'].items():
1893 env['JAVA%s_HOME' % k] = v
1895 missinglocale = True
1896 for k, v in env.items():
1897 if k == 'LANG' and v != 'C':
1898 missinglocale = False
1900 missinglocale = False
1902 env['LANG'] = 'en_US.UTF-8'
1904 if build is not None:
1905 path = build.ndk_path()
1906 paths = orig_path.split(os.pathsep)
1907 if path not in paths:
1908 paths = [path] + paths
1909 env['PATH'] = os.pathsep.join(paths)
1910 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1911 env[n] = build.ndk_path()
1914 def replace_config_vars(cmd, build):
1915 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1916 cmd = cmd.replace('$$NDK$$', build.ndk_path())
1917 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1918 cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1919 if build is not None:
1920 cmd = cmd.replace('$$COMMIT$$', build.commit)
1921 cmd = cmd.replace('$$VERSION$$', build.versionName)
1922 cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1926 def place_srclib(root_dir, number, libpath):
1929 relpath = os.path.relpath(libpath, root_dir)
1930 proppath = os.path.join(root_dir, 'project.properties')
1933 if os.path.isfile(proppath):
1934 with open(proppath, "r", encoding='iso-8859-1') as o:
1935 lines = o.readlines()
1937 with open(proppath, "w", encoding='iso-8859-1') as o:
1940 if line.startswith('android.library.reference.%d=' % number):
1941 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1946 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1949 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1952 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1953 """Verify that two apks are the same
1955 One of the inputs is signed, the other is unsigned. The signature metadata
1956 is transferred from the signed to the unsigned apk, and then jarsigner is
1957 used to verify that the signature from the signed apk is also varlid for
1959 :param signed_apk: Path to a signed apk file
1960 :param unsigned_apk: Path to an unsigned apk file expected to match it
1961 :param tmp_dir: Path to directory for temporary files
1962 :returns: None if the verification is successful, otherwise a string
1963 describing what went wrong.
1965 with ZipFile(signed_apk) as signed_apk_as_zip:
1966 meta_inf_files = ['META-INF/MANIFEST.MF']
1967 for f in signed_apk_as_zip.namelist():
1968 if apk_sigfile.match(f):
1969 meta_inf_files.append(f)
1970 if len(meta_inf_files) < 3:
1971 return "Signature files missing from {0}".format(signed_apk)
1972 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1973 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1974 for meta_inf_file in meta_inf_files:
1975 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1977 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1978 logging.info("...NOT verified - {0}".format(signed_apk))
1979 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1980 logging.info("...successfully verified")
1984 apk_badchars = re.compile('''[/ :;'"]''')
1987 def compare_apks(apk1, apk2, tmp_dir):
1990 Returns None if the apk content is the same (apart from the signing key),
1991 otherwise a string describing what's different, or what went wrong when
1992 trying to do the comparison.
1995 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1996 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1997 for d in [apk1dir, apk2dir]:
1998 if os.path.exists(d):
2001 os.mkdir(os.path.join(d, 'jar-xf'))
2003 if subprocess.call(['jar', 'xf',
2004 os.path.abspath(apk1)],
2005 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2006 return("Failed to unpack " + apk1)
2007 if subprocess.call(['jar', 'xf',
2008 os.path.abspath(apk2)],
2009 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2010 return("Failed to unpack " + apk2)
2012 # try to find apktool in the path, if it hasn't been manually configed
2013 if 'apktool' not in config:
2014 tmp = find_command('apktool')
2016 config['apktool'] = tmp
2017 if 'apktool' in config:
2018 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2020 return("Failed to unpack " + apk1)
2021 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2023 return("Failed to unpack " + apk2)
2025 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2026 lines = p.output.splitlines()
2027 if len(lines) != 1 or 'META-INF' not in lines[0]:
2028 meld = find_command('meld')
2029 if meld is not None:
2030 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2031 return("Unexpected diff output - " + p.output)
2033 # since everything verifies, delete the comparison to keep cruft down
2034 shutil.rmtree(apk1dir)
2035 shutil.rmtree(apk2dir)
2037 # If we get here, it seems like they're the same!
2041 def find_command(command):
2042 '''find the full path of a command, or None if it can't be found in the PATH'''
2045 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2047 fpath, fname = os.path.split(command)
2052 for path in os.environ["PATH"].split(os.pathsep):
2053 path = path.strip('"')
2054 exe_file = os.path.join(path, command)
2055 if is_exe(exe_file):
2062 '''generate a random password for when generating keys'''
2063 h = hashlib.sha256()
2064 h.update(os.urandom(16)) # salt
2065 h.update(socket.getfqdn().encode('utf-8'))
2066 passwd = base64.b64encode(h.digest()).strip()
2067 return passwd.decode('utf-8')
2070 def genkeystore(localconfig):
2071 '''Generate a new key with random passwords and add it to new keystore'''
2072 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2073 keystoredir = os.path.dirname(localconfig['keystore'])
2074 if keystoredir is None or keystoredir == '':
2075 keystoredir = os.path.join(os.getcwd(), keystoredir)
2076 if not os.path.exists(keystoredir):
2077 os.makedirs(keystoredir, mode=0o700)
2079 write_password_file("keystorepass", localconfig['keystorepass'])
2080 write_password_file("keypass", localconfig['keypass'])
2081 p = FDroidPopen([config['keytool'], '-genkey',
2082 '-keystore', localconfig['keystore'],
2083 '-alias', localconfig['repo_keyalias'],
2084 '-keyalg', 'RSA', '-keysize', '4096',
2085 '-sigalg', 'SHA256withRSA',
2086 '-validity', '10000',
2087 '-storepass:file', config['keystorepassfile'],
2088 '-keypass:file', config['keypassfile'],
2089 '-dname', localconfig['keydname']])
2090 # TODO keypass should be sent via stdin
2091 if p.returncode != 0:
2092 raise BuildException("Failed to generate key", p.output)
2093 os.chmod(localconfig['keystore'], 0o0600)
2094 # now show the lovely key that was just generated
2095 p = FDroidPopen([config['keytool'], '-list', '-v',
2096 '-keystore', localconfig['keystore'],
2097 '-alias', localconfig['repo_keyalias'],
2098 '-storepass:file', config['keystorepassfile']])
2099 logging.info(p.output.strip() + '\n\n')
2102 def write_to_config(thisconfig, key, value=None):
2103 '''write a key/value to the local config.py'''
2105 origkey = key + '_orig'
2106 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2107 with open('config.py', 'r', encoding='utf8') as f:
2109 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2110 repl = '\n' + key + ' = "' + value + '"'
2111 data = re.sub(pattern, repl, data)
2112 # if this key is not in the file, append it
2113 if not re.match('\s*' + key + '\s*=\s*"', data):
2115 # make sure the file ends with a carraige return
2116 if not re.match('\n$', data):
2118 with open('config.py', 'w', encoding='utf8') as f:
2122 def parse_xml(path):
2123 return XMLElementTree.parse(path).getroot()
2126 def string_is_integer(string):
2134 def get_per_app_repos():
2135 '''per-app repos are dirs named with the packageName of a single app'''
2137 # Android packageNames are Java packages, they may contain uppercase or
2138 # lowercase letters ('A' through 'Z'), numbers, and underscores
2139 # ('_'). However, individual package name parts may only start with
2140 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2141 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2144 for root, dirs, files in os.walk(os.getcwd()):
2146 print('checking', root, 'for', d)
2147 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2148 # standard parts of an fdroid repo, so never packageNames
2151 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2157 def is_repo_file(filename):
2158 '''Whether the file in a repo is a build product to be delivered to users'''
2159 return os.path.isfile(filename) \
2160 and os.path.basename(filename) not in [