1 # -*- coding: utf-8 -*-
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 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",
59 'r10e': "$ANDROID_NDK",
61 'build_tools': "23.0.2",
66 'accepted_formats': ['txt', 'yaml'],
67 'sync_from_local_copy_dir': False,
68 'per_app_repos': False,
69 'make_current_version_link': True,
70 'current_version_name_source': 'Name',
71 'update_stats': False,
75 'stats_to_carbon': False,
77 'build_server_always': False,
78 'keystore': 'keystore.jks',
79 'smartcardoptions': [],
85 'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
86 'repo_name': "My First FDroid Repo Demo",
87 'repo_icon': "fdroid-icon.png",
88 'repo_description': '''
89 This is a repository of apps to be used with FDroid. Applications in this
90 repository are either official binaries built by the original application
91 developers, or are binaries built from source by the admin of f-droid.org
92 using the tools on https://gitlab.com/u/fdroid.
98 def setup_global_opts(parser):
99 parser.add_argument("-v", "--verbose", action="store_true", default=False,
100 help="Spew out even more information than normal")
101 parser.add_argument("-q", "--quiet", action="store_true", default=False,
102 help="Restrict output to warnings and errors")
105 def fill_config_defaults(thisconfig):
106 for k, v in default_config.items():
107 if k not in thisconfig:
110 # Expand paths (~users and $vars)
111 def expand_path(path):
115 path = os.path.expanduser(path)
116 path = os.path.expandvars(path)
121 for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
126 thisconfig[k + '_orig'] = v
128 # find all installed JDKs for keytool, jarsigner, and JAVA[6-9]_HOME env vars
129 if thisconfig['java_paths'] is None:
130 thisconfig['java_paths'] = dict()
131 for d in sorted(glob.glob('/usr/lib/jvm/j*[6-9]*')
132 + glob.glob('/usr/java/jdk1.[6-9]*')
133 + glob.glob('/System/Library/Java/JavaVirtualMachines/1.[6-9].0.jdk')
134 + glob.glob('/Library/Java/JavaVirtualMachines/*jdk*[6-9]*')):
135 if os.path.islink(d):
137 j = os.path.basename(d)
138 # the last one found will be the canonical one, so order appropriately
140 r'^1\.([6-9])\.0\.jdk$', # OSX
141 r'^jdk1\.([6-9])\.0_[0-9]+.jdk$', # OSX and Oracle tarball
142 r'^jdk([6-9])-openjdk$', # Arch
143 r'^java-([6-9])-openjdk$', # Arch
144 r'^java-([6-9])-jdk$', # Arch (oracle)
145 r'^java-1\.([6-9])\.0-.*$', # RedHat
146 r'^java-([6-9])-oracle$', # Debian WebUpd8
147 r'^jdk-([6-9])-oracle-.*$', # Debian make-jpkg
148 r'^java-([6-9])-openjdk-[^c][^o][^m].*$', # Debian
150 m = re.match(regex, j)
153 osxhome = os.path.join(d, 'Contents', 'Home')
154 if os.path.exists(osxhome):
155 thisconfig['java_paths'][m.group(1)] = osxhome
157 thisconfig['java_paths'][m.group(1)] = d
159 for java_version in ('7', '8', '9'):
160 if java_version not in thisconfig['java_paths']:
162 java_home = thisconfig['java_paths'][java_version]
163 jarsigner = os.path.join(java_home, 'bin', 'jarsigner')
164 if os.path.exists(jarsigner):
165 thisconfig['jarsigner'] = jarsigner
166 thisconfig['keytool'] = os.path.join(java_home, 'bin', 'keytool')
167 break # Java7 is preferred, so quit if found
169 for k in ['ndk_paths', 'java_paths']:
175 thisconfig[k][k2] = exp
176 thisconfig[k][k2 + '_orig'] = v
179 def regsub_file(pattern, repl, path):
180 with open(path, 'r') as f:
182 text = re.sub(pattern, repl, text)
183 with open(path, 'w') as f:
187 def read_config(opts, config_file='config.py'):
188 """Read the repository config
190 The config is read from config_file, which is in the current directory when
191 any of the repo management commands are used.
193 global config, options, env, orig_path
195 if config is not None:
197 if not os.path.isfile(config_file):
198 logging.critical("Missing config file - is this a repo directory?")
205 logging.debug("Reading %s" % config_file)
206 with io.open(config_file, "rb") as f:
207 code = compile(f.read(), config_file, 'exec')
208 exec(code, None, config)
210 # smartcardoptions must be a list since its command line args for Popen
211 if 'smartcardoptions' in config:
212 config['smartcardoptions'] = config['smartcardoptions'].split(' ')
213 elif 'keystore' in config and config['keystore'] == 'NONE':
214 # keystore='NONE' means use smartcard, these are required defaults
215 config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
216 'SunPKCS11-OpenSC', '-providerClass',
217 'sun.security.pkcs11.SunPKCS11',
218 '-providerArg', 'opensc-fdroid.cfg']
220 if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
221 st = os.stat(config_file)
222 if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
223 logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
225 fill_config_defaults(config)
227 # There is no standard, so just set up the most common environment
230 orig_path = env['PATH']
231 for n in ['ANDROID_HOME', 'ANDROID_SDK']:
232 env[n] = config['sdk_path']
234 for k, v in config['java_paths'].items():
235 env['JAVA%s_HOME' % k] = v
237 for k in ["keystorepass", "keypass"]:
239 write_password_file(k)
241 for k in ["repo_description", "archive_description"]:
243 config[k] = clean_description(config[k])
245 if 'serverwebroot' in config:
246 if isinstance(config['serverwebroot'], str):
247 roots = [config['serverwebroot']]
248 elif all(isinstance(item, str) for item in config['serverwebroot']):
249 roots = config['serverwebroot']
251 raise TypeError('only accepts strings, lists, and tuples')
253 for rootstr in roots:
254 # since this is used with rsync, where trailing slashes have
255 # meaning, ensure there is always a trailing slash
256 if rootstr[-1] != '/':
258 rootlist.append(rootstr.replace('//', '/'))
259 config['serverwebroot'] = rootlist
264 def find_sdk_tools_cmd(cmd):
265 '''find a working path to a tool from the Android SDK'''
268 if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
269 # try to find a working path to this command, in all the recent possible paths
270 if 'build_tools' in config:
271 build_tools = os.path.join(config['sdk_path'], 'build-tools')
272 # if 'build_tools' was manually set and exists, check only that one
273 configed_build_tools = os.path.join(build_tools, config['build_tools'])
274 if os.path.exists(configed_build_tools):
275 tooldirs.append(configed_build_tools)
277 # no configed version, so hunt known paths for it
278 for f in sorted(os.listdir(build_tools), reverse=True):
279 if os.path.isdir(os.path.join(build_tools, f)):
280 tooldirs.append(os.path.join(build_tools, f))
281 tooldirs.append(build_tools)
282 sdk_tools = os.path.join(config['sdk_path'], 'tools')
283 if os.path.exists(sdk_tools):
284 tooldirs.append(sdk_tools)
285 sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
286 if os.path.exists(sdk_platform_tools):
287 tooldirs.append(sdk_platform_tools)
288 tooldirs.append('/usr/bin')
290 if os.path.isfile(os.path.join(d, cmd)):
291 return os.path.join(d, cmd)
292 # did not find the command, exit with error message
293 ensure_build_tools_exists(config)
296 def test_sdk_exists(thisconfig):
297 if 'sdk_path' not in thisconfig:
298 if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
301 logging.error("'sdk_path' not set in config.py!")
303 if thisconfig['sdk_path'] == default_config['sdk_path']:
304 logging.error('No Android SDK found!')
305 logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
306 logging.error('\texport ANDROID_HOME=/opt/android-sdk')
308 if not os.path.exists(thisconfig['sdk_path']):
309 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
311 if not os.path.isdir(thisconfig['sdk_path']):
312 logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
314 for d in ['build-tools', 'platform-tools', 'tools']:
315 if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
316 logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
317 thisconfig['sdk_path'], d))
322 def ensure_build_tools_exists(thisconfig):
323 if not test_sdk_exists(thisconfig):
325 build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
326 versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
327 if not os.path.isdir(versioned_build_tools):
328 logging.critical('Android Build Tools path "'
329 + versioned_build_tools + '" does not exist!')
333 def write_password_file(pwtype, password=None):
335 writes out passwords to a protected file instead of passing passwords as
336 command line argments
338 filename = '.fdroid.' + pwtype + '.txt'
339 fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
341 os.write(fd, config[pwtype].encode('utf-8'))
343 os.write(fd, password.encode('utf-8'))
345 config[pwtype + 'file'] = filename
348 # Given the arguments in the form of multiple appid:[vc] strings, this returns
349 # a dictionary with the set of vercodes specified for each package.
350 def read_pkg_args(args, allow_vercodes=False):
357 if allow_vercodes and ':' in p:
358 package, vercode = p.split(':')
360 package, vercode = p, None
361 if package not in vercodes:
362 vercodes[package] = [vercode] if vercode else []
364 elif vercode and vercode not in vercodes[package]:
365 vercodes[package] += [vercode] if vercode else []
370 # On top of what read_pkg_args does, this returns the whole app metadata, but
371 # limiting the builds list to the builds matching the vercodes specified.
372 def read_app_args(args, allapps, allow_vercodes=False):
374 vercodes = read_pkg_args(args, allow_vercodes)
380 for appid, app in allapps.items():
381 if appid in vercodes:
384 if len(apps) != len(vercodes):
387 logging.critical("No such package: %s" % p)
388 raise FDroidException("Found invalid app ids in arguments")
390 raise FDroidException("No packages specified")
393 for appid, app in apps.items():
397 app.builds = [b for b in app.builds if b.vercode in vc]
398 if len(app.builds) != len(vercodes[appid]):
400 allvcs = [b.vercode for b in app.builds]
401 for v in vercodes[appid]:
403 logging.critical("No such vercode %s for app %s" % (v, appid))
406 raise FDroidException("Found invalid vercodes for some apps")
411 def get_extension(filename):
412 base, ext = os.path.splitext(filename)
415 return base, ext.lower()[1:]
418 def has_extension(filename, ext):
419 _, f_ext = get_extension(filename)
423 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
426 def clean_description(description):
427 'Remove unneeded newlines and spaces from a block of description text'
429 # this is split up by paragraph to make removing the newlines easier
430 for paragraph in re.split(r'\n\n', description):
431 paragraph = re.sub('\r', '', paragraph)
432 paragraph = re.sub('\n', ' ', paragraph)
433 paragraph = re.sub(' {2,}', ' ', paragraph)
434 paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
435 returnstring += paragraph + '\n\n'
436 return returnstring.rstrip('\n')
439 def apknameinfo(filename):
440 filename = os.path.basename(filename)
441 m = apk_regex.match(filename)
443 result = (m.group(1), m.group(2))
444 except AttributeError:
445 raise FDroidException("Invalid apk name: %s" % filename)
449 def getapkname(app, build):
450 return "%s_%s.apk" % (app.id, build.vercode)
453 def getsrcname(app, build):
454 return "%s_%s_src.tar.gz" % (app.id, build.vercode)
466 return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
469 def getvcs(vcstype, remote, local):
471 return vcs_git(remote, local)
472 if vcstype == 'git-svn':
473 return vcs_gitsvn(remote, local)
475 return vcs_hg(remote, local)
477 return vcs_bzr(remote, local)
478 if vcstype == 'srclib':
479 if local != os.path.join('build', 'srclib', remote):
480 raise VCSException("Error: srclib paths are hard-coded!")
481 return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
483 raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
484 raise VCSException("Invalid vcs type " + vcstype)
487 def getsrclibvcs(name):
488 if name not in fdroidserver.metadata.srclibs:
489 raise VCSException("Missing srclib " + name)
490 return fdroidserver.metadata.srclibs[name]['Repo Type']
495 def __init__(self, remote, local):
497 # svn, git-svn and bzr may require auth
499 if self.repotype() in ('git-svn', 'bzr'):
501 if self.repotype == 'git-svn':
502 raise VCSException("Authentication is not supported for git-svn")
503 self.username, remote = remote.split('@')
504 if ':' not in self.username:
505 raise VCSException("Password required with username")
506 self.username, self.password = self.username.split(':')
510 self.clone_failed = False
511 self.refreshed = False
517 # Take the local repository to a clean version of the given revision, which
518 # is specificed in the VCS's native format. Beforehand, the repository can
519 # be dirty, or even non-existent. If the repository does already exist
520 # locally, it will be updated from the origin, but only once in the
521 # lifetime of the vcs object.
522 # None is acceptable for 'rev' if you know you are cloning a clean copy of
523 # the repo - otherwise it must specify a valid revision.
524 def gotorevision(self, rev, refresh=True):
526 if self.clone_failed:
527 raise VCSException("Downloading the repository already failed once, not trying again.")
529 # The .fdroidvcs-id file for a repo tells us what VCS type
530 # and remote that directory was created from, allowing us to drop it
531 # automatically if either of those things changes.
532 fdpath = os.path.join(self.local, '..',
533 '.fdroidvcs-' + os.path.basename(self.local))
534 cdata = self.repotype() + ' ' + self.remote
537 if os.path.exists(self.local):
538 if os.path.exists(fdpath):
539 with open(fdpath, 'r') as f:
540 fsdata = f.read().strip()
545 logging.info("Repository details for %s changed - deleting" % (
549 logging.info("Repository details for %s missing - deleting" % (
552 shutil.rmtree(self.local)
556 self.refreshed = True
559 self.gotorevisionx(rev)
560 except FDroidException as e:
563 # If necessary, write the .fdroidvcs file.
564 if writeback and not self.clone_failed:
565 with open(fdpath, 'w') as f:
571 # Derived classes need to implement this. It's called once basic checking
572 # has been performend.
573 def gotorevisionx(self, rev):
574 raise VCSException("This VCS type doesn't define gotorevisionx")
576 # Initialise and update submodules
577 def initsubmodules(self):
578 raise VCSException('Submodules not supported for this vcs type')
580 # Get a list of all known tags
582 if not self._gettags:
583 raise VCSException('gettags not supported for this vcs type')
585 for tag in self._gettags():
586 if re.match('[-A-Za-z0-9_. /]+$', tag):
590 def latesttags(self, tags, number):
591 """Get the most recent tags in a given list.
593 :param tags: a list of tags
594 :param number: the number to return
595 :returns: A list containing the most recent tags in the provided
596 list, up to the maximum number given.
598 raise VCSException('latesttags not supported for this vcs type')
600 # Get current commit reference (hash, revision, etc)
602 raise VCSException('getref not supported for this vcs type')
604 # Returns the srclib (name, path) used in setting up the current
615 # If the local directory exists, but is somehow not a git repository, git
616 # will traverse up the directory tree until it finds one that is (i.e.
617 # fdroidserver) and then we'll proceed to destroy it! This is called as
620 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
621 result = p.output.rstrip()
622 if not result.endswith(self.local):
623 raise VCSException('Repository mismatch')
625 def gotorevisionx(self, rev):
626 if not os.path.exists(self.local):
628 p = FDroidPopen(['git', 'clone', self.remote, self.local])
629 if p.returncode != 0:
630 self.clone_failed = True
631 raise VCSException("Git clone failed", p.output)
635 # Discard any working tree changes
636 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
637 'git', 'reset', '--hard'], cwd=self.local, output=False)
638 if p.returncode != 0:
639 raise VCSException("Git reset failed", p.output)
640 # Remove untracked files now, in case they're tracked in the target
641 # revision (it happens!)
642 p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
643 'git', 'clean', '-dffx'], cwd=self.local, output=False)
644 if p.returncode != 0:
645 raise VCSException("Git clean failed", p.output)
646 if not self.refreshed:
647 # Get latest commits and tags from remote
648 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
649 if p.returncode != 0:
650 raise VCSException("Git fetch failed", p.output)
651 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
652 if p.returncode != 0:
653 raise VCSException("Git fetch failed", p.output)
654 # Recreate origin/HEAD as git clone would do it, in case it disappeared
655 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
656 if p.returncode != 0:
657 lines = p.output.splitlines()
658 if 'Multiple remote HEAD branches' not in lines[0]:
659 raise VCSException("Git remote set-head failed", p.output)
660 branch = lines[1].split(' ')[-1]
661 p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
662 if p2.returncode != 0:
663 raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
664 self.refreshed = True
665 # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
666 # a github repo. Most of the time this is the same as origin/master.
667 rev = rev or 'origin/HEAD'
668 p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
669 if p.returncode != 0:
670 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
671 # Get rid of any uncontrolled files left behind
672 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
673 if p.returncode != 0:
674 raise VCSException("Git clean failed", p.output)
676 def initsubmodules(self):
678 submfile = os.path.join(self.local, '.gitmodules')
679 if not os.path.isfile(submfile):
680 raise VCSException("No git submodules available")
682 # fix submodules not accessible without an account and public key auth
683 with open(submfile, 'r') as f:
684 lines = f.readlines()
685 with open(submfile, 'w') as f:
687 if 'git@github.com' in line:
688 line = line.replace('git@github.com:', 'https://github.com/')
689 if 'git@gitlab.com' in line:
690 line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
693 p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
694 if p.returncode != 0:
695 raise VCSException("Git submodule sync failed", p.output)
696 p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
697 if p.returncode != 0:
698 raise VCSException("Git submodule update failed", p.output)
702 p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
703 return p.output.splitlines()
705 def latesttags(self, tags, number):
710 ['git', 'show', '--format=format:%ct', '-s', tag],
711 cwd=self.local, output=False)
712 # Timestamp is on the last line. For a normal tag, it's the only
713 # line, but for annotated tags, the rest of the info precedes it.
714 ts = int(p.output.splitlines()[-1])
717 for _, t in sorted(tl)[-number:]:
722 class vcs_gitsvn(vcs):
727 # If the local directory exists, but is somehow not a git repository, git
728 # will traverse up the directory tree until it finds one that is (i.e.
729 # fdroidserver) and then we'll proceed to destory it! This is called as
732 p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
733 result = p.output.rstrip()
734 if not result.endswith(self.local):
735 raise VCSException('Repository mismatch')
737 def gotorevisionx(self, rev):
738 if not os.path.exists(self.local):
740 gitsvn_args = ['git', 'svn', 'clone']
741 if ';' in self.remote:
742 remote_split = self.remote.split(';')
743 for i in remote_split[1:]:
744 if i.startswith('trunk='):
745 gitsvn_args.extend(['-T', i[6:]])
746 elif i.startswith('tags='):
747 gitsvn_args.extend(['-t', i[5:]])
748 elif i.startswith('branches='):
749 gitsvn_args.extend(['-b', i[9:]])
750 gitsvn_args.extend([remote_split[0], self.local])
751 p = FDroidPopen(gitsvn_args, output=False)
752 if p.returncode != 0:
753 self.clone_failed = True
754 raise VCSException("Git svn clone failed", p.output)
756 gitsvn_args.extend([self.remote, self.local])
757 p = FDroidPopen(gitsvn_args, output=False)
758 if p.returncode != 0:
759 self.clone_failed = True
760 raise VCSException("Git svn clone failed", p.output)
764 # Discard any working tree changes
765 p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
766 if p.returncode != 0:
767 raise VCSException("Git reset failed", p.output)
768 # Remove untracked files now, in case they're tracked in the target
769 # revision (it happens!)
770 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
771 if p.returncode != 0:
772 raise VCSException("Git clean failed", p.output)
773 if not self.refreshed:
774 # Get new commits, branches and tags from repo
775 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
776 if p.returncode != 0:
777 raise VCSException("Git svn fetch failed")
778 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
779 if p.returncode != 0:
780 raise VCSException("Git svn rebase failed", p.output)
781 self.refreshed = True
783 rev = rev or 'master'
785 nospaces_rev = rev.replace(' ', '%20')
786 # Try finding a svn tag
787 for treeish in ['origin/', '']:
788 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
789 if p.returncode == 0:
791 if p.returncode != 0:
792 # No tag found, normal svn rev translation
793 # Translate svn rev into git format
794 rev_split = rev.split('/')
797 for treeish in ['origin/', '']:
798 if len(rev_split) > 1:
799 treeish += rev_split[0]
800 svn_rev = rev_split[1]
803 # if no branch is specified, then assume trunk (i.e. 'master' branch):
807 svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
809 p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
810 git_rev = p.output.rstrip()
812 if p.returncode == 0 and git_rev:
815 if p.returncode != 0 or not git_rev:
816 # Try a plain git checkout as a last resort
817 p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
818 if p.returncode != 0:
819 raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
821 # Check out the git rev equivalent to the svn rev
822 p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
823 if p.returncode != 0:
824 raise VCSException("Git checkout of '%s' failed" % rev, p.output)
826 # Get rid of any uncontrolled files left behind
827 p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
828 if p.returncode != 0:
829 raise VCSException("Git clean failed", p.output)
833 for treeish in ['origin/', '']:
834 d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
840 p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
841 if p.returncode != 0:
843 return p.output.strip()
851 def gotorevisionx(self, rev):
852 if not os.path.exists(self.local):
853 p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
854 if p.returncode != 0:
855 self.clone_failed = True
856 raise VCSException("Hg clone failed", p.output)
858 p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
859 if p.returncode != 0:
860 raise VCSException("Hg status failed", p.output)
861 for line in p.output.splitlines():
862 if not line.startswith('? '):
863 raise VCSException("Unexpected output from hg status -uS: " + line)
864 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
865 if not self.refreshed:
866 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
867 if p.returncode != 0:
868 raise VCSException("Hg pull failed", p.output)
869 self.refreshed = True
871 rev = rev or 'default'
874 p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
875 if p.returncode != 0:
876 raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
877 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
878 # Also delete untracked files, we have to enable purge extension for that:
879 if "'purge' is provided by the following extension" in p.output:
880 with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
881 myfile.write("\n[extensions]\nhgext.purge=\n")
882 p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
883 if p.returncode != 0:
884 raise VCSException("HG purge failed", p.output)
885 elif p.returncode != 0:
886 raise VCSException("HG purge failed", p.output)
889 p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
890 return p.output.splitlines()[1:]
898 def gotorevisionx(self, rev):
899 if not os.path.exists(self.local):
900 p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
901 if p.returncode != 0:
902 self.clone_failed = True
903 raise VCSException("Bzr branch failed", p.output)
905 p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
906 if p.returncode != 0:
907 raise VCSException("Bzr revert failed", p.output)
908 if not self.refreshed:
909 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
910 if p.returncode != 0:
911 raise VCSException("Bzr update failed", p.output)
912 self.refreshed = True
914 revargs = list(['-r', rev] if rev else [])
915 p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
916 if p.returncode != 0:
917 raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
920 p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
921 return [tag.split(' ')[0].strip() for tag in
922 p.output.splitlines()]
925 def unescape_string(string):
928 if string[0] == '"' and string[-1] == '"':
931 return string.replace("\\'", "'")
934 def retrieve_string(app_dir, string, xmlfiles=None):
936 if not string.startswith('@string/'):
937 return unescape_string(string)
942 os.path.join(app_dir, 'res'),
943 os.path.join(app_dir, 'src', 'main', 'res'),
945 for r, d, f in os.walk(res_dir):
946 if os.path.basename(r) == 'values':
947 xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
949 name = string[len('@string/'):]
951 def element_content(element):
952 if element.text is None:
954 s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
955 return s.decode('utf-8').strip()
957 for path in xmlfiles:
958 if not os.path.isfile(path):
960 xml = parse_xml(path)
961 element = xml.find('string[@name="' + name + '"]')
962 if element is not None:
963 content = element_content(element)
964 return retrieve_string(app_dir, content, xmlfiles)
969 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
970 return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
973 # Return list of existing files that will be used to find the highest vercode
974 def manifest_paths(app_dir, flavours):
976 possible_manifests = \
977 [os.path.join(app_dir, 'AndroidManifest.xml'),
978 os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
979 os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
980 os.path.join(app_dir, 'build.gradle')]
982 for flavour in flavours:
985 possible_manifests.append(
986 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
988 return [path for path in possible_manifests if os.path.isfile(path)]
991 # Retrieve the package name. Returns the name, or None if not found.
992 def fetch_real_name(app_dir, flavours):
993 for path in manifest_paths(app_dir, flavours):
994 if not has_extension(path, 'xml') or not os.path.isfile(path):
996 logging.debug("fetch_real_name: Checking manifest at " + path)
997 xml = parse_xml(path)
998 app = xml.find('application')
1001 if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1003 label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1004 result = retrieve_string_singleline(app_dir, label)
1006 result = result.strip()
1011 def get_library_references(root_dir):
1013 proppath = os.path.join(root_dir, 'project.properties')
1014 if not os.path.isfile(proppath):
1016 with open(proppath, 'r') as f:
1018 if not line.startswith('android.library.reference.'):
1020 path = line.split('=')[1].strip()
1021 relpath = os.path.join(root_dir, path)
1022 if not os.path.isdir(relpath):
1024 logging.debug("Found subproject at %s" % path)
1025 libraries.append(path)
1029 def ant_subprojects(root_dir):
1030 subprojects = get_library_references(root_dir)
1031 for subpath in subprojects:
1032 subrelpath = os.path.join(root_dir, subpath)
1033 for p in get_library_references(subrelpath):
1034 relp = os.path.normpath(os.path.join(subpath, p))
1035 if relp not in subprojects:
1036 subprojects.insert(0, relp)
1040 def remove_debuggable_flags(root_dir):
1041 # Remove forced debuggable flags
1042 logging.debug("Removing debuggable flags from %s" % root_dir)
1043 for root, dirs, files in os.walk(root_dir):
1044 if 'AndroidManifest.xml' in files:
1045 regsub_file(r'android:debuggable="[^"]*"',
1047 os.path.join(root, 'AndroidManifest.xml'))
1050 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1051 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1052 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1055 def app_matches_packagename(app, package):
1058 appid = app.UpdateCheckName or app.id
1059 if appid is None or appid == "Ignore":
1061 return appid == package
1064 # Extract some information from the AndroidManifest.xml at the given path.
1065 # Returns (version, vercode, package), any or all of which might be None.
1066 # All values returned are strings.
1067 def parse_androidmanifests(paths, app):
1069 ignoreversions = app.UpdateCheckIgnore
1070 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1073 return (None, None, None)
1081 if not os.path.isfile(path):
1084 logging.debug("Parsing manifest at {0}".format(path))
1085 gradle = has_extension(path, 'gradle')
1091 with open(path, 'r') as f:
1093 if gradle_comment.match(line):
1095 # Grab first occurence of each to avoid running into
1096 # alternative flavours and builds.
1098 matches = psearch_g(line)
1100 s = matches.group(2)
1101 if app_matches_packagename(app, s):
1104 matches = vnsearch_g(line)
1106 version = matches.group(2)
1108 matches = vcsearch_g(line)
1110 vercode = matches.group(1)
1113 xml = parse_xml(path)
1114 if "package" in xml.attrib:
1115 s = xml.attrib["package"]
1116 if app_matches_packagename(app, s):
1118 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1119 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1120 base_dir = os.path.dirname(path)
1121 version = retrieve_string_singleline(base_dir, version)
1122 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1123 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1124 if string_is_integer(a):
1127 logging.warning("Problem with xml at {0}".format(path))
1129 # Remember package name, may be defined separately from version+vercode
1131 package = max_package
1133 logging.debug("..got package={0}, version={1}, vercode={2}"
1134 .format(package, version, vercode))
1136 # Always grab the package name and version name in case they are not
1137 # together with the highest version code
1138 if max_package is None and package is not None:
1139 max_package = package
1140 if max_version is None and version is not None:
1141 max_version = version
1143 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1144 if not ignoresearch or not ignoresearch(version):
1145 if version is not None:
1146 max_version = version
1147 if vercode is not None:
1148 max_vercode = vercode
1149 if package is not None:
1150 max_package = package
1152 max_version = "Ignore"
1154 if max_version is None:
1155 max_version = "Unknown"
1157 if max_package and not is_valid_package_name(max_package):
1158 raise FDroidException("Invalid package name {0}".format(max_package))
1160 return (max_version, max_vercode, max_package)
1163 def is_valid_package_name(name):
1164 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1167 class FDroidException(Exception):
1169 def __init__(self, value, detail=None):
1171 self.detail = detail
1173 def shortened_detail(self):
1174 if len(self.detail) < 16000:
1176 return '[...]\n' + self.detail[-16000:]
1178 def get_wikitext(self):
1179 ret = repr(self.value) + "\n"
1182 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1188 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1192 class VCSException(FDroidException):
1196 class BuildException(FDroidException):
1200 # Get the specified source library.
1201 # Returns the path to it. Normally this is the path to be used when referencing
1202 # it, which may be a subdirectory of the actual project. If you want the base
1203 # directory of the project, pass 'basepath=True'.
1204 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1205 raw=False, prepare=True, preponly=False, refresh=True):
1213 name, ref = spec.split('@')
1215 number, name = name.split(':', 1)
1217 name, subdir = name.split('/', 1)
1219 if name not in fdroidserver.metadata.srclibs:
1220 raise VCSException('srclib ' + name + ' not found.')
1222 srclib = fdroidserver.metadata.srclibs[name]
1224 sdir = os.path.join(srclib_dir, name)
1227 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1228 vcs.srclib = (name, number, sdir)
1230 vcs.gotorevision(ref, refresh)
1237 libdir = os.path.join(sdir, subdir)
1238 elif srclib["Subdir"]:
1239 for subdir in srclib["Subdir"]:
1240 libdir_candidate = os.path.join(sdir, subdir)
1241 if os.path.exists(libdir_candidate):
1242 libdir = libdir_candidate
1248 remove_signing_keys(sdir)
1249 remove_debuggable_flags(sdir)
1253 if srclib["Prepare"]:
1254 cmd = replace_config_vars(srclib["Prepare"], None)
1256 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1257 if p.returncode != 0:
1258 raise BuildException("Error running prepare command for srclib %s"
1264 return (name, number, libdir)
1266 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1269 # Prepare the source code for a particular build
1270 # 'vcs' - the appropriate vcs object for the application
1271 # 'app' - the application details from the metadata
1272 # 'build' - the build details from the metadata
1273 # 'build_dir' - the path to the build directory, usually
1275 # 'srclib_dir' - the path to the source libraries directory, usually
1277 # 'extlib_dir' - the path to the external libraries directory, usually
1279 # Returns the (root, srclibpaths) where:
1280 # 'root' is the root directory, which may be the same as 'build_dir' or may
1281 # be a subdirectory of it.
1282 # 'srclibpaths' is information on the srclibs being used
1283 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1285 # Optionally, the actual app source can be in a subdirectory
1287 root_dir = os.path.join(build_dir, build.subdir)
1289 root_dir = build_dir
1291 # Get a working copy of the right revision
1292 logging.info("Getting source for revision " + build.commit)
1293 vcs.gotorevision(build.commit, refresh)
1295 # Initialise submodules if required
1296 if build.submodules:
1297 logging.info("Initialising submodules")
1298 vcs.initsubmodules()
1300 # Check that a subdir (if we're using one) exists. This has to happen
1301 # after the checkout, since it might not exist elsewhere
1302 if not os.path.exists(root_dir):
1303 raise BuildException('Missing subdir ' + root_dir)
1305 # Run an init command if one is required
1307 cmd = replace_config_vars(build.init, build)
1308 logging.info("Running 'init' commands in %s" % root_dir)
1310 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1311 if p.returncode != 0:
1312 raise BuildException("Error running init command for %s:%s" %
1313 (app.id, build.version), p.output)
1315 # Apply patches if any
1317 logging.info("Applying patches")
1318 for patch in build.patch:
1319 patch = patch.strip()
1320 logging.info("Applying " + patch)
1321 patch_path = os.path.join('metadata', app.id, patch)
1322 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1323 if p.returncode != 0:
1324 raise BuildException("Failed to apply patch %s" % patch_path)
1326 # Get required source libraries
1329 logging.info("Collecting source libraries")
1330 for lib in build.srclibs:
1331 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1333 for name, number, libpath in srclibpaths:
1334 place_srclib(root_dir, int(number) if number else None, libpath)
1336 basesrclib = vcs.getsrclib()
1337 # If one was used for the main source, add that too.
1339 srclibpaths.append(basesrclib)
1341 # Update the local.properties file
1342 localprops = [os.path.join(build_dir, 'local.properties')]
1344 parts = build.subdir.split(os.sep)
1347 cur = os.path.join(cur, d)
1348 localprops += [os.path.join(cur, 'local.properties')]
1349 for path in localprops:
1351 if os.path.isfile(path):
1352 logging.info("Updating local.properties file at %s" % path)
1353 with open(path, 'r') as f:
1357 logging.info("Creating local.properties file at %s" % path)
1358 # Fix old-fashioned 'sdk-location' by copying
1359 # from sdk.dir, if necessary
1361 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1362 re.S | re.M).group(1)
1363 props += "sdk-location=%s\n" % sdkloc
1365 props += "sdk.dir=%s\n" % config['sdk_path']
1366 props += "sdk-location=%s\n" % config['sdk_path']
1367 ndk_path = build.ndk_path()
1370 props += "ndk.dir=%s\n" % ndk_path
1371 props += "ndk-location=%s\n" % ndk_path
1372 # Add java.encoding if necessary
1374 props += "java.encoding=%s\n" % build.encoding
1375 with open(path, 'w') as f:
1379 if build.build_method() == 'gradle':
1380 flavours = build.gradle
1383 n = build.target.split('-')[1]
1384 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1385 r'compileSdkVersion %s' % n,
1386 os.path.join(root_dir, 'build.gradle'))
1388 # Remove forced debuggable flags
1389 remove_debuggable_flags(root_dir)
1391 # Insert version code and number into the manifest if necessary
1392 if build.forceversion:
1393 logging.info("Changing the version name")
1394 for path in manifest_paths(root_dir, flavours):
1395 if not os.path.isfile(path):
1397 if has_extension(path, 'xml'):
1398 regsub_file(r'android:versionName="[^"]*"',
1399 r'android:versionName="%s"' % build.version,
1401 elif has_extension(path, 'gradle'):
1402 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1403 r"""\1versionName '%s'""" % build.version,
1406 if build.forcevercode:
1407 logging.info("Changing the version code")
1408 for path in manifest_paths(root_dir, flavours):
1409 if not os.path.isfile(path):
1411 if has_extension(path, 'xml'):
1412 regsub_file(r'android:versionCode="[^"]*"',
1413 r'android:versionCode="%s"' % build.vercode,
1415 elif has_extension(path, 'gradle'):
1416 regsub_file(r'versionCode[ =]+[0-9]+',
1417 r'versionCode %s' % build.vercode,
1420 # Delete unwanted files
1422 logging.info("Removing specified files")
1423 for part in getpaths(build_dir, build.rm):
1424 dest = os.path.join(build_dir, part)
1425 logging.info("Removing {0}".format(part))
1426 if os.path.lexists(dest):
1427 if os.path.islink(dest):
1428 FDroidPopen(['unlink', dest], output=False)
1430 FDroidPopen(['rm', '-rf', dest], output=False)
1432 logging.info("...but it didn't exist")
1434 remove_signing_keys(build_dir)
1436 # Add required external libraries
1438 logging.info("Collecting prebuilt libraries")
1439 libsdir = os.path.join(root_dir, 'libs')
1440 if not os.path.exists(libsdir):
1442 for lib in build.extlibs:
1444 logging.info("...installing extlib {0}".format(lib))
1445 libf = os.path.basename(lib)
1446 libsrc = os.path.join(extlib_dir, lib)
1447 if not os.path.exists(libsrc):
1448 raise BuildException("Missing extlib file {0}".format(libsrc))
1449 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1451 # Run a pre-build command if one is required
1453 logging.info("Running 'prebuild' commands in %s" % root_dir)
1455 cmd = replace_config_vars(build.prebuild, build)
1457 # Substitute source library paths into prebuild commands
1458 for name, number, libpath in srclibpaths:
1459 libpath = os.path.relpath(libpath, root_dir)
1460 cmd = cmd.replace('$$' + name + '$$', libpath)
1462 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1463 if p.returncode != 0:
1464 raise BuildException("Error running prebuild command for %s:%s" %
1465 (app.id, build.version), p.output)
1467 # Generate (or update) the ant build file, build.xml...
1468 if build.build_method() == 'ant' and build.update != ['no']:
1469 parms = ['android', 'update', 'lib-project']
1470 lparms = ['android', 'update', 'project']
1473 parms += ['-t', build.target]
1474 lparms += ['-t', build.target]
1476 update_dirs = build.update
1478 update_dirs = ant_subprojects(root_dir) + ['.']
1480 for d in update_dirs:
1481 subdir = os.path.join(root_dir, d)
1483 logging.debug("Updating main project")
1484 cmd = parms + ['-p', d]
1486 logging.debug("Updating subproject %s" % d)
1487 cmd = lparms + ['-p', d]
1488 p = SdkToolsPopen(cmd, cwd=root_dir)
1489 # Check to see whether an error was returned without a proper exit
1490 # code (this is the case for the 'no target set or target invalid'
1492 if p.returncode != 0 or p.output.startswith("Error: "):
1493 raise BuildException("Failed to update project at %s" % d, p.output)
1494 # Clean update dirs via ant
1496 logging.info("Cleaning subproject %s" % d)
1497 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1499 return (root_dir, srclibpaths)
1502 # Extend via globbing the paths from a field and return them as a map from
1503 # original path to resulting paths
1504 def getpaths_map(build_dir, globpaths):
1508 full_path = os.path.join(build_dir, p)
1509 full_path = os.path.normpath(full_path)
1510 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1512 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1516 # Extend via globbing the paths from a field and return them as a set
1517 def getpaths(build_dir, globpaths):
1518 paths_map = getpaths_map(build_dir, globpaths)
1520 for k, v in paths_map.items():
1527 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1533 self.path = os.path.join('stats', 'known_apks.txt')
1535 if os.path.isfile(self.path):
1536 with open(self.path, 'r') as f:
1538 t = line.rstrip().split(' ')
1540 self.apks[t[0]] = (t[1], None)
1542 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1543 self.changed = False
1545 def writeifchanged(self):
1546 if not self.changed:
1549 if not os.path.exists('stats'):
1553 for apk, app in self.apks.items():
1555 line = apk + ' ' + appid
1557 line += ' ' + time.strftime('%Y-%m-%d', added)
1560 with open(self.path, 'w') as f:
1561 for line in sorted(lst, key=natural_key):
1562 f.write(line + '\n')
1564 # Record an apk (if it's new, otherwise does nothing)
1565 # Returns the date it was added.
1566 def recordapk(self, apk, app):
1567 if apk not in self.apks:
1568 self.apks[apk] = (app, time.gmtime(time.time()))
1570 _, added = self.apks[apk]
1573 # Look up information - given the 'apkname', returns (app id, date added/None).
1574 # Or returns None for an unknown apk.
1575 def getapp(self, apkname):
1576 if apkname in self.apks:
1577 return self.apks[apkname]
1580 # Get the most recent 'num' apps added to the repo, as a list of package ids
1581 # with the most recent first.
1582 def getlatest(self, num):
1584 for apk, app in self.apks.items():
1588 if apps[appid] > added:
1592 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1593 lst = [app for app, _ in sortedapps]
1598 def isApkDebuggable(apkfile, config):
1599 """Returns True if the given apk file is debuggable
1601 :param apkfile: full path to the apk to check"""
1603 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1605 if p.returncode != 0:
1606 logging.critical("Failed to get apk manifest information")
1608 for line in p.output.splitlines():
1609 if 'android:debuggable' in line and not line.endswith('0x0'):
1619 def SdkToolsPopen(commands, cwd=None, output=True):
1621 if cmd not in config:
1622 config[cmd] = find_sdk_tools_cmd(commands[0])
1623 abscmd = config[cmd]
1625 logging.critical("Could not find '%s' on your system" % cmd)
1627 return FDroidPopen([abscmd] + commands[1:],
1628 cwd=cwd, output=output)
1631 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1633 Run a command and capture the possibly huge output.
1635 :param commands: command and argument list like in subprocess.Popen
1636 :param cwd: optionally specifies a working directory
1637 :returns: A PopenResult.
1643 cwd = os.path.normpath(cwd)
1644 logging.debug("Directory: %s" % cwd)
1645 logging.debug("> %s" % ' '.join(commands))
1647 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1648 result = PopenResult()
1651 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1652 stdout=subprocess.PIPE, stderr=stderr_param)
1653 except OSError as e:
1654 raise BuildException("OSError while trying to execute " +
1655 ' '.join(commands) + ': ' + str(e))
1657 if not stderr_to_stdout and options.verbose:
1658 stderr_queue = Queue()
1659 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1661 while not stderr_reader.eof():
1662 while not stderr_queue.empty():
1663 line = stderr_queue.get()
1664 sys.stderr.write(line)
1669 stdout_queue = Queue()
1670 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1672 # Check the queue for output (until there is no more to get)
1673 while not stdout_reader.eof():
1674 while not stdout_queue.empty():
1675 line = stdout_queue.get()
1676 if output and options.verbose:
1677 # Output directly to console
1678 sys.stderr.buffer.write(line)
1680 result.output += line.decode('utf-8')
1684 result.returncode = p.wait()
1688 gradle_comment = re.compile(r'[ ]*//')
1689 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1690 gradle_line_matches = [
1691 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1692 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1693 re.compile(r'.*\.readLine\(.*'),
1697 def remove_signing_keys(build_dir):
1698 for root, dirs, files in os.walk(build_dir):
1699 if 'build.gradle' in files:
1700 path = os.path.join(root, 'build.gradle')
1702 with open(path, "r") as o:
1703 lines = o.readlines()
1709 with open(path, "w") as o:
1710 while i < len(lines):
1713 while line.endswith('\\\n'):
1714 line = line.rstrip('\\\n') + lines[i]
1717 if gradle_comment.match(line):
1722 opened += line.count('{')
1723 opened -= line.count('}')
1726 if gradle_signing_configs.match(line):
1731 if any(s.match(line) for s in gradle_line_matches):
1739 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1742 'project.properties',
1744 'default.properties',
1745 'ant.properties', ]:
1746 if propfile in files:
1747 path = os.path.join(root, propfile)
1749 with open(path, "r") as o:
1750 lines = o.readlines()
1754 with open(path, "w") as o:
1756 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1763 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1766 def reset_env_path():
1767 global env, orig_path
1768 env['PATH'] = orig_path
1771 def add_to_env_path(path):
1773 paths = env['PATH'].split(os.pathsep)
1777 env['PATH'] = os.pathsep.join(paths)
1780 def replace_config_vars(cmd, build):
1782 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1783 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1784 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1785 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1786 if build is not None:
1787 cmd = cmd.replace('$$COMMIT$$', build.commit)
1788 cmd = cmd.replace('$$VERSION$$', build.version)
1789 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1793 def place_srclib(root_dir, number, libpath):
1796 relpath = os.path.relpath(libpath, root_dir)
1797 proppath = os.path.join(root_dir, 'project.properties')
1800 if os.path.isfile(proppath):
1801 with open(proppath, "r") as o:
1802 lines = o.readlines()
1804 with open(proppath, "w") as o:
1807 if line.startswith('android.library.reference.%d=' % number):
1808 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1813 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1815 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1818 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1819 """Verify that two apks are the same
1821 One of the inputs is signed, the other is unsigned. The signature metadata
1822 is transferred from the signed to the unsigned apk, and then jarsigner is
1823 used to verify that the signature from the signed apk is also varlid for
1825 :param signed_apk: Path to a signed apk file
1826 :param unsigned_apk: Path to an unsigned apk file expected to match it
1827 :param tmp_dir: Path to directory for temporary files
1828 :returns: None if the verification is successful, otherwise a string
1829 describing what went wrong.
1831 with ZipFile(signed_apk) as signed_apk_as_zip:
1832 meta_inf_files = ['META-INF/MANIFEST.MF']
1833 for f in signed_apk_as_zip.namelist():
1834 if apk_sigfile.match(f):
1835 meta_inf_files.append(f)
1836 if len(meta_inf_files) < 3:
1837 return "Signature files missing from {0}".format(signed_apk)
1838 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1839 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1840 for meta_inf_file in meta_inf_files:
1841 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1843 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1844 logging.info("...NOT verified - {0}".format(signed_apk))
1845 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1846 logging.info("...successfully verified")
1849 apk_badchars = re.compile('''[/ :;'"]''')
1852 def compare_apks(apk1, apk2, tmp_dir):
1855 Returns None if the apk content is the same (apart from the signing key),
1856 otherwise a string describing what's different, or what went wrong when
1857 trying to do the comparison.
1860 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1861 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1862 for d in [apk1dir, apk2dir]:
1863 if os.path.exists(d):
1866 os.mkdir(os.path.join(d, 'jar-xf'))
1868 if subprocess.call(['jar', 'xf',
1869 os.path.abspath(apk1)],
1870 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1871 return("Failed to unpack " + apk1)
1872 if subprocess.call(['jar', 'xf',
1873 os.path.abspath(apk2)],
1874 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1875 return("Failed to unpack " + apk2)
1877 # try to find apktool in the path, if it hasn't been manually configed
1878 if 'apktool' not in config:
1879 tmp = find_command('apktool')
1881 config['apktool'] = tmp
1882 if 'apktool' in config:
1883 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1885 return("Failed to unpack " + apk1)
1886 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1888 return("Failed to unpack " + apk2)
1890 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1891 lines = p.output.splitlines()
1892 if len(lines) != 1 or 'META-INF' not in lines[0]:
1893 meld = find_command('meld')
1894 if meld is not None:
1895 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1896 return("Unexpected diff output - " + p.output)
1898 # since everything verifies, delete the comparison to keep cruft down
1899 shutil.rmtree(apk1dir)
1900 shutil.rmtree(apk2dir)
1902 # If we get here, it seems like they're the same!
1906 def find_command(command):
1907 '''find the full path of a command, or None if it can't be found in the PATH'''
1910 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1912 fpath, fname = os.path.split(command)
1917 for path in os.environ["PATH"].split(os.pathsep):
1918 path = path.strip('"')
1919 exe_file = os.path.join(path, command)
1920 if is_exe(exe_file):
1927 '''generate a random password for when generating keys'''
1928 h = hashlib.sha256()
1929 h.update(os.urandom(16)) # salt
1930 h.update(socket.getfqdn().encode('utf-8'))
1931 passwd = base64.b64encode(h.digest()).strip()
1932 return passwd.decode('utf-8')
1935 def genkeystore(localconfig):
1936 '''Generate a new key with random passwords and add it to new keystore'''
1937 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1938 keystoredir = os.path.dirname(localconfig['keystore'])
1939 if keystoredir is None or keystoredir == '':
1940 keystoredir = os.path.join(os.getcwd(), keystoredir)
1941 if not os.path.exists(keystoredir):
1942 os.makedirs(keystoredir, mode=0o700)
1944 write_password_file("keystorepass", localconfig['keystorepass'])
1945 write_password_file("keypass", localconfig['keypass'])
1946 p = FDroidPopen([config['keytool'], '-genkey',
1947 '-keystore', localconfig['keystore'],
1948 '-alias', localconfig['repo_keyalias'],
1949 '-keyalg', 'RSA', '-keysize', '4096',
1950 '-sigalg', 'SHA256withRSA',
1951 '-validity', '10000',
1952 '-storepass:file', config['keystorepassfile'],
1953 '-keypass:file', config['keypassfile'],
1954 '-dname', localconfig['keydname']])
1955 # TODO keypass should be sent via stdin
1956 if p.returncode != 0:
1957 raise BuildException("Failed to generate key", p.output)
1958 os.chmod(localconfig['keystore'], 0o0600)
1959 # now show the lovely key that was just generated
1960 p = FDroidPopen([config['keytool'], '-list', '-v',
1961 '-keystore', localconfig['keystore'],
1962 '-alias', localconfig['repo_keyalias'],
1963 '-storepass:file', config['keystorepassfile']])
1964 logging.info(p.output.strip() + '\n\n')
1967 def write_to_config(thisconfig, key, value=None):
1968 '''write a key/value to the local config.py'''
1970 origkey = key + '_orig'
1971 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1972 with open('config.py', 'r') as f:
1974 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1975 repl = '\n' + key + ' = "' + value + '"'
1976 data = re.sub(pattern, repl, data)
1977 # if this key is not in the file, append it
1978 if not re.match('\s*' + key + '\s*=\s*"', data):
1980 # make sure the file ends with a carraige return
1981 if not re.match('\n$', data):
1983 with open('config.py', 'w') as f:
1987 def parse_xml(path):
1988 return XMLElementTree.parse(path).getroot()
1991 def string_is_integer(string):
1999 def get_per_app_repos():
2000 '''per-app repos are dirs named with the packageName of a single app'''
2002 # Android packageNames are Java packages, they may contain uppercase or
2003 # lowercase letters ('A' through 'Z'), numbers, and underscores
2004 # ('_'). However, individual package name parts may only start with
2005 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2006 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2009 for root, dirs, files in os.walk(os.getcwd()):
2011 print('checking', root, 'for', d)
2012 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2013 # standard parts of an fdroid repo, so never packageNames
2016 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):