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])
343 os.write(fd, password)
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')
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"].encode('utf-8')
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 for line in file(proppath):
1017 if not line.startswith('android.library.reference.'):
1019 path = line.split('=')[1].strip()
1020 relpath = os.path.join(root_dir, path)
1021 if not os.path.isdir(relpath):
1023 logging.debug("Found subproject at %s" % path)
1024 libraries.append(path)
1028 def ant_subprojects(root_dir):
1029 subprojects = get_library_references(root_dir)
1030 for subpath in subprojects:
1031 subrelpath = os.path.join(root_dir, subpath)
1032 for p in get_library_references(subrelpath):
1033 relp = os.path.normpath(os.path.join(subpath, p))
1034 if relp not in subprojects:
1035 subprojects.insert(0, relp)
1039 def remove_debuggable_flags(root_dir):
1040 # Remove forced debuggable flags
1041 logging.debug("Removing debuggable flags from %s" % root_dir)
1042 for root, dirs, files in os.walk(root_dir):
1043 if 'AndroidManifest.xml' in files:
1044 regsub_file(r'android:debuggable="[^"]*"',
1046 os.path.join(root, 'AndroidManifest.xml'))
1049 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1050 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1051 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1054 def app_matches_packagename(app, package):
1057 appid = app.UpdateCheckName or app.id
1058 if appid is None or appid == "Ignore":
1060 return appid == package
1063 # Extract some information from the AndroidManifest.xml at the given path.
1064 # Returns (version, vercode, package), any or all of which might be None.
1065 # All values returned are strings.
1066 def parse_androidmanifests(paths, app):
1068 ignoreversions = app.UpdateCheckIgnore
1069 ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1072 return (None, None, None)
1080 if not os.path.isfile(path):
1083 logging.debug("Parsing manifest at {0}".format(path))
1084 gradle = has_extension(path, 'gradle')
1090 for line in file(path):
1091 if gradle_comment.match(line):
1093 # Grab first occurence of each to avoid running into
1094 # alternative flavours and builds.
1096 matches = psearch_g(line)
1098 s = matches.group(2)
1099 if app_matches_packagename(app, s):
1102 matches = vnsearch_g(line)
1104 version = matches.group(2)
1106 matches = vcsearch_g(line)
1108 vercode = matches.group(1)
1111 xml = parse_xml(path)
1112 if "package" in xml.attrib:
1113 s = xml.attrib["package"].encode('utf-8')
1114 if app_matches_packagename(app, s):
1116 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1117 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1118 base_dir = os.path.dirname(path)
1119 version = retrieve_string_singleline(base_dir, version)
1120 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1121 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1122 if string_is_integer(a):
1125 logging.warning("Problem with xml at {0}".format(path))
1127 # Remember package name, may be defined separately from version+vercode
1129 package = max_package
1131 logging.debug("..got package={0}, version={1}, vercode={2}"
1132 .format(package, version, vercode))
1134 # Always grab the package name and version name in case they are not
1135 # together with the highest version code
1136 if max_package is None and package is not None:
1137 max_package = package
1138 if max_version is None and version is not None:
1139 max_version = version
1141 if max_vercode is None or (vercode is not None and vercode > max_vercode):
1142 if not ignoresearch or not ignoresearch(version):
1143 if version is not None:
1144 max_version = version
1145 if vercode is not None:
1146 max_vercode = vercode
1147 if package is not None:
1148 max_package = package
1150 max_version = "Ignore"
1152 if max_version is None:
1153 max_version = "Unknown"
1155 if max_package and not is_valid_package_name(max_package):
1156 raise FDroidException("Invalid package name {0}".format(max_package))
1158 return (max_version, max_vercode, max_package)
1161 def is_valid_package_name(name):
1162 return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1165 class FDroidException(Exception):
1167 def __init__(self, value, detail=None):
1169 self.detail = detail
1171 def shortened_detail(self):
1172 if len(self.detail) < 16000:
1174 return '[...]\n' + self.detail[-16000:]
1176 def get_wikitext(self):
1177 ret = repr(self.value) + "\n"
1180 ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1186 ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1190 class VCSException(FDroidException):
1194 class BuildException(FDroidException):
1198 # Get the specified source library.
1199 # Returns the path to it. Normally this is the path to be used when referencing
1200 # it, which may be a subdirectory of the actual project. If you want the base
1201 # directory of the project, pass 'basepath=True'.
1202 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1203 raw=False, prepare=True, preponly=False, refresh=True):
1211 name, ref = spec.split('@')
1213 number, name = name.split(':', 1)
1215 name, subdir = name.split('/', 1)
1217 if name not in fdroidserver.metadata.srclibs:
1218 raise VCSException('srclib ' + name + ' not found.')
1220 srclib = fdroidserver.metadata.srclibs[name]
1222 sdir = os.path.join(srclib_dir, name)
1225 vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1226 vcs.srclib = (name, number, sdir)
1228 vcs.gotorevision(ref, refresh)
1235 libdir = os.path.join(sdir, subdir)
1236 elif srclib["Subdir"]:
1237 for subdir in srclib["Subdir"]:
1238 libdir_candidate = os.path.join(sdir, subdir)
1239 if os.path.exists(libdir_candidate):
1240 libdir = libdir_candidate
1246 remove_signing_keys(sdir)
1247 remove_debuggable_flags(sdir)
1251 if srclib["Prepare"]:
1252 cmd = replace_config_vars(srclib["Prepare"], None)
1254 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1255 if p.returncode != 0:
1256 raise BuildException("Error running prepare command for srclib %s"
1262 return (name, number, libdir)
1264 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1267 # Prepare the source code for a particular build
1268 # 'vcs' - the appropriate vcs object for the application
1269 # 'app' - the application details from the metadata
1270 # 'build' - the build details from the metadata
1271 # 'build_dir' - the path to the build directory, usually
1273 # 'srclib_dir' - the path to the source libraries directory, usually
1275 # 'extlib_dir' - the path to the external libraries directory, usually
1277 # Returns the (root, srclibpaths) where:
1278 # 'root' is the root directory, which may be the same as 'build_dir' or may
1279 # be a subdirectory of it.
1280 # 'srclibpaths' is information on the srclibs being used
1281 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1283 # Optionally, the actual app source can be in a subdirectory
1285 root_dir = os.path.join(build_dir, build.subdir)
1287 root_dir = build_dir
1289 # Get a working copy of the right revision
1290 logging.info("Getting source for revision " + build.commit)
1291 vcs.gotorevision(build.commit, refresh)
1293 # Initialise submodules if required
1294 if build.submodules:
1295 logging.info("Initialising submodules")
1296 vcs.initsubmodules()
1298 # Check that a subdir (if we're using one) exists. This has to happen
1299 # after the checkout, since it might not exist elsewhere
1300 if not os.path.exists(root_dir):
1301 raise BuildException('Missing subdir ' + root_dir)
1303 # Run an init command if one is required
1305 cmd = replace_config_vars(build.init, build)
1306 logging.info("Running 'init' commands in %s" % root_dir)
1308 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1309 if p.returncode != 0:
1310 raise BuildException("Error running init command for %s:%s" %
1311 (app.id, build.version), p.output)
1313 # Apply patches if any
1315 logging.info("Applying patches")
1316 for patch in build.patch:
1317 patch = patch.strip()
1318 logging.info("Applying " + patch)
1319 patch_path = os.path.join('metadata', app.id, patch)
1320 p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1321 if p.returncode != 0:
1322 raise BuildException("Failed to apply patch %s" % patch_path)
1324 # Get required source libraries
1327 logging.info("Collecting source libraries")
1328 for lib in build.srclibs:
1329 srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1331 for name, number, libpath in srclibpaths:
1332 place_srclib(root_dir, int(number) if number else None, libpath)
1334 basesrclib = vcs.getsrclib()
1335 # If one was used for the main source, add that too.
1337 srclibpaths.append(basesrclib)
1339 # Update the local.properties file
1340 localprops = [os.path.join(build_dir, 'local.properties')]
1342 parts = build.subdir.split(os.sep)
1345 cur = os.path.join(cur, d)
1346 localprops += [os.path.join(cur, 'local.properties')]
1347 for path in localprops:
1349 if os.path.isfile(path):
1350 logging.info("Updating local.properties file at %s" % path)
1351 with open(path, 'r') as f:
1355 logging.info("Creating local.properties file at %s" % path)
1356 # Fix old-fashioned 'sdk-location' by copying
1357 # from sdk.dir, if necessary
1359 sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1360 re.S | re.M).group(1)
1361 props += "sdk-location=%s\n" % sdkloc
1363 props += "sdk.dir=%s\n" % config['sdk_path']
1364 props += "sdk-location=%s\n" % config['sdk_path']
1365 ndk_path = build.ndk_path()
1368 props += "ndk.dir=%s\n" % ndk_path
1369 props += "ndk-location=%s\n" % ndk_path
1370 # Add java.encoding if necessary
1372 props += "java.encoding=%s\n" % build.encoding
1373 with open(path, 'w') as f:
1377 if build.build_method() == 'gradle':
1378 flavours = build.gradle
1381 n = build.target.split('-')[1]
1382 regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1383 r'compileSdkVersion %s' % n,
1384 os.path.join(root_dir, 'build.gradle'))
1386 # Remove forced debuggable flags
1387 remove_debuggable_flags(root_dir)
1389 # Insert version code and number into the manifest if necessary
1390 if build.forceversion:
1391 logging.info("Changing the version name")
1392 for path in manifest_paths(root_dir, flavours):
1393 if not os.path.isfile(path):
1395 if has_extension(path, 'xml'):
1396 regsub_file(r'android:versionName="[^"]*"',
1397 r'android:versionName="%s"' % build.version,
1399 elif has_extension(path, 'gradle'):
1400 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1401 r"""\1versionName '%s'""" % build.version,
1404 if build.forcevercode:
1405 logging.info("Changing the version code")
1406 for path in manifest_paths(root_dir, flavours):
1407 if not os.path.isfile(path):
1409 if has_extension(path, 'xml'):
1410 regsub_file(r'android:versionCode="[^"]*"',
1411 r'android:versionCode="%s"' % build.vercode,
1413 elif has_extension(path, 'gradle'):
1414 regsub_file(r'versionCode[ =]+[0-9]+',
1415 r'versionCode %s' % build.vercode,
1418 # Delete unwanted files
1420 logging.info("Removing specified files")
1421 for part in getpaths(build_dir, build.rm):
1422 dest = os.path.join(build_dir, part)
1423 logging.info("Removing {0}".format(part))
1424 if os.path.lexists(dest):
1425 if os.path.islink(dest):
1426 FDroidPopen(['unlink', dest], output=False)
1428 FDroidPopen(['rm', '-rf', dest], output=False)
1430 logging.info("...but it didn't exist")
1432 remove_signing_keys(build_dir)
1434 # Add required external libraries
1436 logging.info("Collecting prebuilt libraries")
1437 libsdir = os.path.join(root_dir, 'libs')
1438 if not os.path.exists(libsdir):
1440 for lib in build.extlibs:
1442 logging.info("...installing extlib {0}".format(lib))
1443 libf = os.path.basename(lib)
1444 libsrc = os.path.join(extlib_dir, lib)
1445 if not os.path.exists(libsrc):
1446 raise BuildException("Missing extlib file {0}".format(libsrc))
1447 shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1449 # Run a pre-build command if one is required
1451 logging.info("Running 'prebuild' commands in %s" % root_dir)
1453 cmd = replace_config_vars(build.prebuild, build)
1455 # Substitute source library paths into prebuild commands
1456 for name, number, libpath in srclibpaths:
1457 libpath = os.path.relpath(libpath, root_dir)
1458 cmd = cmd.replace('$$' + name + '$$', libpath)
1460 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1461 if p.returncode != 0:
1462 raise BuildException("Error running prebuild command for %s:%s" %
1463 (app.id, build.version), p.output)
1465 # Generate (or update) the ant build file, build.xml...
1466 if build.build_method() == 'ant' and build.update != ['no']:
1467 parms = ['android', 'update', 'lib-project']
1468 lparms = ['android', 'update', 'project']
1471 parms += ['-t', build.target]
1472 lparms += ['-t', build.target]
1474 update_dirs = build.update
1476 update_dirs = ant_subprojects(root_dir) + ['.']
1478 for d in update_dirs:
1479 subdir = os.path.join(root_dir, d)
1481 logging.debug("Updating main project")
1482 cmd = parms + ['-p', d]
1484 logging.debug("Updating subproject %s" % d)
1485 cmd = lparms + ['-p', d]
1486 p = SdkToolsPopen(cmd, cwd=root_dir)
1487 # Check to see whether an error was returned without a proper exit
1488 # code (this is the case for the 'no target set or target invalid'
1490 if p.returncode != 0 or p.output.startswith("Error: "):
1491 raise BuildException("Failed to update project at %s" % d, p.output)
1492 # Clean update dirs via ant
1494 logging.info("Cleaning subproject %s" % d)
1495 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1497 return (root_dir, srclibpaths)
1500 # Extend via globbing the paths from a field and return them as a map from
1501 # original path to resulting paths
1502 def getpaths_map(build_dir, globpaths):
1506 full_path = os.path.join(build_dir, p)
1507 full_path = os.path.normpath(full_path)
1508 paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1510 raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1514 # Extend via globbing the paths from a field and return them as a set
1515 def getpaths(build_dir, globpaths):
1516 paths_map = getpaths_map(build_dir, globpaths)
1518 for k, v in paths_map.items():
1525 return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1531 self.path = os.path.join('stats', 'known_apks.txt')
1533 if os.path.isfile(self.path):
1534 for line in file(self.path):
1535 t = line.rstrip().split(' ')
1537 self.apks[t[0]] = (t[1], None)
1539 self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1540 self.changed = False
1542 def writeifchanged(self):
1543 if not self.changed:
1546 if not os.path.exists('stats'):
1550 for apk, app in self.apks.items():
1552 line = apk + ' ' + appid
1554 line += ' ' + time.strftime('%Y-%m-%d', added)
1557 with open(self.path, 'w') as f:
1558 for line in sorted(lst, key=natural_key):
1559 f.write(line + '\n')
1561 # Record an apk (if it's new, otherwise does nothing)
1562 # Returns the date it was added.
1563 def recordapk(self, apk, app):
1564 if apk not in self.apks:
1565 self.apks[apk] = (app, time.gmtime(time.time()))
1567 _, added = self.apks[apk]
1570 # Look up information - given the 'apkname', returns (app id, date added/None).
1571 # Or returns None for an unknown apk.
1572 def getapp(self, apkname):
1573 if apkname in self.apks:
1574 return self.apks[apkname]
1577 # Get the most recent 'num' apps added to the repo, as a list of package ids
1578 # with the most recent first.
1579 def getlatest(self, num):
1581 for apk, app in self.apks.items():
1585 if apps[appid] > added:
1589 sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1590 lst = [app for app, _ in sortedapps]
1595 def isApkDebuggable(apkfile, config):
1596 """Returns True if the given apk file is debuggable
1598 :param apkfile: full path to the apk to check"""
1600 p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1602 if p.returncode != 0:
1603 logging.critical("Failed to get apk manifest information")
1605 for line in p.output.splitlines():
1606 if 'android:debuggable' in line and not line.endswith('0x0'):
1616 def SdkToolsPopen(commands, cwd=None, output=True):
1618 if cmd not in config:
1619 config[cmd] = find_sdk_tools_cmd(commands[0])
1620 abscmd = config[cmd]
1622 logging.critical("Could not find '%s' on your system" % cmd)
1624 return FDroidPopen([abscmd] + commands[1:],
1625 cwd=cwd, output=output)
1628 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1630 Run a command and capture the possibly huge output.
1632 :param commands: command and argument list like in subprocess.Popen
1633 :param cwd: optionally specifies a working directory
1634 :returns: A PopenResult.
1640 cwd = os.path.normpath(cwd)
1641 logging.debug("Directory: %s" % cwd)
1642 logging.debug("> %s" % ' '.join(commands))
1644 stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1645 result = PopenResult()
1648 p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1649 stdout=subprocess.PIPE, stderr=stderr_param)
1650 except OSError as e:
1651 raise BuildException("OSError while trying to execute " +
1652 ' '.join(commands) + ': ' + str(e))
1654 if not stderr_to_stdout and options.verbose:
1655 stderr_queue = Queue()
1656 stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1658 while not stderr_reader.eof():
1659 while not stderr_queue.empty():
1660 line = stderr_queue.get()
1661 sys.stderr.write(line)
1666 stdout_queue = Queue()
1667 stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1669 # Check the queue for output (until there is no more to get)
1670 while not stdout_reader.eof():
1671 while not stdout_queue.empty():
1672 line = stdout_queue.get()
1673 if output and options.verbose:
1674 # Output directly to console
1675 sys.stderr.buffer.write(line)
1677 result.output += line.decode('utf-8')
1681 result.returncode = p.wait()
1685 gradle_comment = re.compile(r'[ ]*//')
1686 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1687 gradle_line_matches = [
1688 re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1689 re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1690 re.compile(r'.*\.readLine\(.*'),
1694 def remove_signing_keys(build_dir):
1695 for root, dirs, files in os.walk(build_dir):
1696 if 'build.gradle' in files:
1697 path = os.path.join(root, 'build.gradle')
1699 with open(path, "r") as o:
1700 lines = o.readlines()
1706 with open(path, "w") as o:
1707 while i < len(lines):
1710 while line.endswith('\\\n'):
1711 line = line.rstrip('\\\n') + lines[i]
1714 if gradle_comment.match(line):
1719 opened += line.count('{')
1720 opened -= line.count('}')
1723 if gradle_signing_configs.match(line):
1728 if any(s.match(line) for s in gradle_line_matches):
1736 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1739 'project.properties',
1741 'default.properties',
1742 'ant.properties', ]:
1743 if propfile in files:
1744 path = os.path.join(root, propfile)
1746 with open(path, "r") as o:
1747 lines = o.readlines()
1751 with open(path, "w") as o:
1753 if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1760 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1763 def reset_env_path():
1764 global env, orig_path
1765 env['PATH'] = orig_path
1768 def add_to_env_path(path):
1770 paths = env['PATH'].split(os.pathsep)
1774 env['PATH'] = os.pathsep.join(paths)
1777 def replace_config_vars(cmd, build):
1779 cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1780 # env['ANDROID_NDK'] is set in build_local right before prepare_source
1781 cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1782 cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1783 if build is not None:
1784 cmd = cmd.replace('$$COMMIT$$', build.commit)
1785 cmd = cmd.replace('$$VERSION$$', build.version)
1786 cmd = cmd.replace('$$VERCODE$$', build.vercode)
1790 def place_srclib(root_dir, number, libpath):
1793 relpath = os.path.relpath(libpath, root_dir)
1794 proppath = os.path.join(root_dir, 'project.properties')
1797 if os.path.isfile(proppath):
1798 with open(proppath, "r") as o:
1799 lines = o.readlines()
1801 with open(proppath, "w") as o:
1804 if line.startswith('android.library.reference.%d=' % number):
1805 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1810 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1812 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1815 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1816 """Verify that two apks are the same
1818 One of the inputs is signed, the other is unsigned. The signature metadata
1819 is transferred from the signed to the unsigned apk, and then jarsigner is
1820 used to verify that the signature from the signed apk is also varlid for
1822 :param signed_apk: Path to a signed apk file
1823 :param unsigned_apk: Path to an unsigned apk file expected to match it
1824 :param tmp_dir: Path to directory for temporary files
1825 :returns: None if the verification is successful, otherwise a string
1826 describing what went wrong.
1828 with ZipFile(signed_apk) as signed_apk_as_zip:
1829 meta_inf_files = ['META-INF/MANIFEST.MF']
1830 for f in signed_apk_as_zip.namelist():
1831 if apk_sigfile.match(f):
1832 meta_inf_files.append(f)
1833 if len(meta_inf_files) < 3:
1834 return "Signature files missing from {0}".format(signed_apk)
1835 signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1836 with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1837 for meta_inf_file in meta_inf_files:
1838 unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1840 if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1841 logging.info("...NOT verified - {0}".format(signed_apk))
1842 return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1843 logging.info("...successfully verified")
1846 apk_badchars = re.compile('''[/ :;'"]''')
1849 def compare_apks(apk1, apk2, tmp_dir):
1852 Returns None if the apk content is the same (apart from the signing key),
1853 otherwise a string describing what's different, or what went wrong when
1854 trying to do the comparison.
1857 apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4])) # trim .apk
1858 apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4])) # trim .apk
1859 for d in [apk1dir, apk2dir]:
1860 if os.path.exists(d):
1863 os.mkdir(os.path.join(d, 'jar-xf'))
1865 if subprocess.call(['jar', 'xf',
1866 os.path.abspath(apk1)],
1867 cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1868 return("Failed to unpack " + apk1)
1869 if subprocess.call(['jar', 'xf',
1870 os.path.abspath(apk2)],
1871 cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1872 return("Failed to unpack " + apk2)
1874 # try to find apktool in the path, if it hasn't been manually configed
1875 if 'apktool' not in config:
1876 tmp = find_command('apktool')
1878 config['apktool'] = tmp
1879 if 'apktool' in config:
1880 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1882 return("Failed to unpack " + apk1)
1883 if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1885 return("Failed to unpack " + apk2)
1887 p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1888 lines = p.output.splitlines()
1889 if len(lines) != 1 or 'META-INF' not in lines[0]:
1890 meld = find_command('meld')
1891 if meld is not None:
1892 p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1893 return("Unexpected diff output - " + p.output)
1895 # since everything verifies, delete the comparison to keep cruft down
1896 shutil.rmtree(apk1dir)
1897 shutil.rmtree(apk2dir)
1899 # If we get here, it seems like they're the same!
1903 def find_command(command):
1904 '''find the full path of a command, or None if it can't be found in the PATH'''
1907 return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1909 fpath, fname = os.path.split(command)
1914 for path in os.environ["PATH"].split(os.pathsep):
1915 path = path.strip('"')
1916 exe_file = os.path.join(path, command)
1917 if is_exe(exe_file):
1924 '''generate a random password for when generating keys'''
1925 h = hashlib.sha256()
1926 h.update(os.urandom(16)) # salt
1927 h.update(socket.getfqdn().encode('utf-8'))
1928 passwd = base64.b64encode(h.digest()).strip()
1929 return passwd.decode('utf-8')
1932 def genkeystore(localconfig):
1933 '''Generate a new key with random passwords and add it to new keystore'''
1934 logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1935 keystoredir = os.path.dirname(localconfig['keystore'])
1936 if keystoredir is None or keystoredir == '':
1937 keystoredir = os.path.join(os.getcwd(), keystoredir)
1938 if not os.path.exists(keystoredir):
1939 os.makedirs(keystoredir, mode=0o700)
1941 write_password_file("keystorepass", localconfig['keystorepass'])
1942 write_password_file("keypass", localconfig['keypass'])
1943 p = FDroidPopen([config['keytool'], '-genkey',
1944 '-keystore', localconfig['keystore'],
1945 '-alias', localconfig['repo_keyalias'],
1946 '-keyalg', 'RSA', '-keysize', '4096',
1947 '-sigalg', 'SHA256withRSA',
1948 '-validity', '10000',
1949 '-storepass:file', config['keystorepassfile'],
1950 '-keypass:file', config['keypassfile'],
1951 '-dname', localconfig['keydname']])
1952 # TODO keypass should be sent via stdin
1953 if p.returncode != 0:
1954 raise BuildException("Failed to generate key", p.output)
1955 os.chmod(localconfig['keystore'], 0o0600)
1956 # now show the lovely key that was just generated
1957 p = FDroidPopen([config['keytool'], '-list', '-v',
1958 '-keystore', localconfig['keystore'],
1959 '-alias', localconfig['repo_keyalias'],
1960 '-storepass:file', config['keystorepassfile']])
1961 logging.info(p.output.strip() + '\n\n')
1964 def write_to_config(thisconfig, key, value=None):
1965 '''write a key/value to the local config.py'''
1967 origkey = key + '_orig'
1968 value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1969 with open('config.py', 'r') as f:
1971 pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1972 repl = '\n' + key + ' = "' + value + '"'
1973 data = re.sub(pattern, repl, data)
1974 # if this key is not in the file, append it
1975 if not re.match('\s*' + key + '\s*=\s*"', data):
1977 # make sure the file ends with a carraige return
1978 if not re.match('\n$', data):
1980 with open('config.py', 'w') as f:
1984 def parse_xml(path):
1985 return XMLElementTree.parse(path).getroot()
1988 def string_is_integer(string):
1996 def get_per_app_repos():
1997 '''per-app repos are dirs named with the packageName of a single app'''
1999 # Android packageNames are Java packages, they may contain uppercase or
2000 # lowercase letters ('A' through 'Z'), numbers, and underscores
2001 # ('_'). However, individual package name parts may only start with
2002 # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2003 p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2006 for root, dirs, files in os.walk(os.getcwd()):
2008 print('checking', root, 'for', d)
2009 if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2010 # standard parts of an fdroid repo, so never packageNames
2013 and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):