chiark / gitweb /
Other minor file reading fixes
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
2 #
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>
6 #
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.
11 #
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.
16 #
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/>.
19
20 import os
21 import sys
22 import re
23 import shutil
24 import glob
25 import stat
26 import subprocess
27 import time
28 import operator
29 import Queue
30 import threading
31 import magic
32 import logging
33 from distutils.version import LooseVersion
34 from zipfile import ZipFile
35
36 import metadata
37
38 config = None
39 options = None
40 env = None
41 orig_path = None
42
43
44 default_config = {
45     'sdk_path': "$ANDROID_HOME",
46     'ndk_paths': {
47         'r9b': None,
48         'r10d': "$ANDROID_NDK"
49     },
50     'build_tools': "22.0.0",
51     'ant': "ant",
52     'mvn3': "mvn",
53     'gradle': 'gradle',
54     'sync_from_local_copy_dir': False,
55     'make_current_version_link': True,
56     'current_version_name_source': 'Name',
57     'update_stats': False,
58     'stats_ignore': [],
59     'stats_server': None,
60     'stats_user': None,
61     'stats_to_carbon': False,
62     'repo_maxage': 0,
63     'build_server_always': False,
64     'keystore': os.path.join("$HOME", '.local', 'share', 'fdroidserver', 'keystore.jks'),
65     'smartcardoptions': [],
66     'char_limits': {
67         'Summary': 50,
68         'Description': 1500
69     },
70     'keyaliases': {},
71     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
72     'repo_name': "My First FDroid Repo Demo",
73     'repo_icon': "fdroid-icon.png",
74     'repo_description': '''
75         This is a repository of apps to be used with FDroid. Applications in this
76         repository are either official binaries built by the original application
77         developers, or are binaries built from source by the admin of f-droid.org
78         using the tools on https://gitlab.com/u/fdroid.
79         ''',
80     'archive_older': 0,
81 }
82
83
84 def fill_config_defaults(thisconfig):
85     for k, v in default_config.items():
86         if k not in thisconfig:
87             thisconfig[k] = v
88
89     # Expand paths (~users and $vars)
90     def expand_path(path):
91         if path is None:
92             return None
93         orig = path
94         path = os.path.expanduser(path)
95         path = os.path.expandvars(path)
96         if orig == path:
97             return None
98         return path
99
100     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
101         v = thisconfig[k]
102         exp = expand_path(v)
103         if exp is not None:
104             thisconfig[k] = exp
105             thisconfig[k + '_orig'] = v
106
107     for k in ['ndk_paths']:
108         d = thisconfig[k]
109         for k2 in d.copy():
110             v = d[k2]
111             exp = expand_path(v)
112             if exp is not None:
113                 thisconfig[k][k2] = exp
114                 thisconfig[k][k2 + '_orig'] = v
115
116
117 def read_config(opts, config_file='config.py'):
118     """Read the repository config
119
120     The config is read from config_file, which is in the current directory when
121     any of the repo management commands are used.
122     """
123     global config, options, env, orig_path
124
125     if config is not None:
126         return config
127     if not os.path.isfile(config_file):
128         logging.critical("Missing config file - is this a repo directory?")
129         sys.exit(2)
130
131     options = opts
132
133     config = {}
134
135     logging.debug("Reading %s" % config_file)
136     execfile(config_file, config)
137
138     # smartcardoptions must be a list since its command line args for Popen
139     if 'smartcardoptions' in config:
140         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
141     elif 'keystore' in config and config['keystore'] == 'NONE':
142         # keystore='NONE' means use smartcard, these are required defaults
143         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
144                                       'SunPKCS11-OpenSC', '-providerClass',
145                                       'sun.security.pkcs11.SunPKCS11',
146                                       '-providerArg', 'opensc-fdroid.cfg']
147
148     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
149         st = os.stat(config_file)
150         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
151             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
152
153     fill_config_defaults(config)
154
155     # There is no standard, so just set up the most common environment
156     # variables
157     env = os.environ
158     orig_path = env['PATH']
159     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
160         env[n] = config['sdk_path']
161
162     for k in ["keystorepass", "keypass"]:
163         if k in config:
164             write_password_file(k)
165
166     for k in ["repo_description", "archive_description"]:
167         if k in config:
168             config[k] = clean_description(config[k])
169
170     if 'serverwebroot' in config:
171         if isinstance(config['serverwebroot'], basestring):
172             roots = [config['serverwebroot']]
173         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
174             roots = config['serverwebroot']
175         else:
176             raise TypeError('only accepts strings, lists, and tuples')
177         rootlist = []
178         for rootstr in roots:
179             # since this is used with rsync, where trailing slashes have
180             # meaning, ensure there is always a trailing slash
181             if rootstr[-1] != '/':
182                 rootstr += '/'
183             rootlist.append(rootstr.replace('//', '/'))
184         config['serverwebroot'] = rootlist
185
186     return config
187
188
189 def get_ndk_path(version):
190     if version is None:
191         version = 'r10d'  # latest
192     paths = config['ndk_paths']
193     if version not in paths:
194         return ''
195     return paths[version] or ''
196
197
198 def find_sdk_tools_cmd(cmd):
199     '''find a working path to a tool from the Android SDK'''
200
201     tooldirs = []
202     if config is not None and 'sdk_path' in config and os.path.exists(config['sdk_path']):
203         # try to find a working path to this command, in all the recent possible paths
204         if 'build_tools' in config:
205             build_tools = os.path.join(config['sdk_path'], 'build-tools')
206             # if 'build_tools' was manually set and exists, check only that one
207             configed_build_tools = os.path.join(build_tools, config['build_tools'])
208             if os.path.exists(configed_build_tools):
209                 tooldirs.append(configed_build_tools)
210             else:
211                 # no configed version, so hunt known paths for it
212                 for f in sorted(os.listdir(build_tools), reverse=True):
213                     if os.path.isdir(os.path.join(build_tools, f)):
214                         tooldirs.append(os.path.join(build_tools, f))
215                 tooldirs.append(build_tools)
216         sdk_tools = os.path.join(config['sdk_path'], 'tools')
217         if os.path.exists(sdk_tools):
218             tooldirs.append(sdk_tools)
219         sdk_platform_tools = os.path.join(config['sdk_path'], 'platform-tools')
220         if os.path.exists(sdk_platform_tools):
221             tooldirs.append(sdk_platform_tools)
222     tooldirs.append('/usr/bin')
223     for d in tooldirs:
224         if os.path.isfile(os.path.join(d, cmd)):
225             return os.path.join(d, cmd)
226     # did not find the command, exit with error message
227     ensure_build_tools_exists(config)
228
229
230 def test_sdk_exists(thisconfig):
231     if 'sdk_path' not in thisconfig:
232         if 'aapt' in thisconfig and os.path.isfile(thisconfig['aapt']):
233             return True
234         else:
235             logging.error("'sdk_path' not set in config.py!")
236             return False
237     if thisconfig['sdk_path'] == default_config['sdk_path']:
238         logging.error('No Android SDK found!')
239         logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
240         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
241         return False
242     if not os.path.exists(thisconfig['sdk_path']):
243         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
244         return False
245     if not os.path.isdir(thisconfig['sdk_path']):
246         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
247         return False
248     for d in ['build-tools', 'platform-tools', 'tools']:
249         if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
250             logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
251                 thisconfig['sdk_path'], d))
252             return False
253     return True
254
255
256 def ensure_build_tools_exists(thisconfig):
257     if not test_sdk_exists(thisconfig):
258         sys.exit(3)
259     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
260     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
261     if not os.path.isdir(versioned_build_tools):
262         logging.critical('Android Build Tools path "'
263                          + versioned_build_tools + '" does not exist!')
264         sys.exit(3)
265
266
267 def write_password_file(pwtype, password=None):
268     '''
269     writes out passwords to a protected file instead of passing passwords as
270     command line argments
271     '''
272     filename = '.fdroid.' + pwtype + '.txt'
273     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
274     if password is None:
275         os.write(fd, config[pwtype])
276     else:
277         os.write(fd, password)
278     os.close(fd)
279     config[pwtype + 'file'] = filename
280
281
282 # Given the arguments in the form of multiple appid:[vc] strings, this returns
283 # a dictionary with the set of vercodes specified for each package.
284 def read_pkg_args(args, allow_vercodes=False):
285
286     vercodes = {}
287     if not args:
288         return vercodes
289
290     for p in args:
291         if allow_vercodes and ':' in p:
292             package, vercode = p.split(':')
293         else:
294             package, vercode = p, None
295         if package not in vercodes:
296             vercodes[package] = [vercode] if vercode else []
297             continue
298         elif vercode and vercode not in vercodes[package]:
299             vercodes[package] += [vercode] if vercode else []
300
301     return vercodes
302
303
304 # On top of what read_pkg_args does, this returns the whole app metadata, but
305 # limiting the builds list to the builds matching the vercodes specified.
306 def read_app_args(args, allapps, allow_vercodes=False):
307
308     vercodes = read_pkg_args(args, allow_vercodes)
309
310     if not vercodes:
311         return allapps
312
313     apps = {}
314     for appid, app in allapps.iteritems():
315         if appid in vercodes:
316             apps[appid] = app
317
318     if len(apps) != len(vercodes):
319         for p in vercodes:
320             if p not in allapps:
321                 logging.critical("No such package: %s" % p)
322         raise FDroidException("Found invalid app ids in arguments")
323     if not apps:
324         raise FDroidException("No packages specified")
325
326     error = False
327     for appid, app in apps.iteritems():
328         vc = vercodes[appid]
329         if not vc:
330             continue
331         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
332         if len(app['builds']) != len(vercodes[appid]):
333             error = True
334             allvcs = [b['vercode'] for b in app['builds']]
335             for v in vercodes[appid]:
336                 if v not in allvcs:
337                     logging.critical("No such vercode %s for app %s" % (v, appid))
338
339     if error:
340         raise FDroidException("Found invalid vercodes for some apps")
341
342     return apps
343
344
345 def has_extension(filename, extension):
346     name, ext = os.path.splitext(filename)
347     ext = ext.lower()[1:]
348     return ext == extension
349
350 apk_regex = None
351
352
353 def clean_description(description):
354     'Remove unneeded newlines and spaces from a block of description text'
355     returnstring = ''
356     # this is split up by paragraph to make removing the newlines easier
357     for paragraph in re.split(r'\n\n', description):
358         paragraph = re.sub('\r', '', paragraph)
359         paragraph = re.sub('\n', ' ', paragraph)
360         paragraph = re.sub(' {2,}', ' ', paragraph)
361         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
362         returnstring += paragraph + '\n\n'
363     return returnstring.rstrip('\n')
364
365
366 def apknameinfo(filename):
367     global apk_regex
368     filename = os.path.basename(filename)
369     if apk_regex is None:
370         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
371     m = apk_regex.match(filename)
372     try:
373         result = (m.group(1), m.group(2))
374     except AttributeError:
375         raise FDroidException("Invalid apk name: %s" % filename)
376     return result
377
378
379 def getapkname(app, build):
380     return "%s_%s.apk" % (app['id'], build['vercode'])
381
382
383 def getsrcname(app, build):
384     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
385
386
387 def getappname(app):
388     if app['Name']:
389         return app['Name']
390     if app['Auto Name']:
391         return app['Auto Name']
392     return app['id']
393
394
395 def getcvname(app):
396     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
397
398
399 def getvcs(vcstype, remote, local):
400     if vcstype == 'git':
401         return vcs_git(remote, local)
402     if vcstype == 'git-svn':
403         return vcs_gitsvn(remote, local)
404     if vcstype == 'hg':
405         return vcs_hg(remote, local)
406     if vcstype == 'bzr':
407         return vcs_bzr(remote, local)
408     if vcstype == 'srclib':
409         if local != os.path.join('build', 'srclib', remote):
410             raise VCSException("Error: srclib paths are hard-coded!")
411         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
412     if vcstype == 'svn':
413         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
414     raise VCSException("Invalid vcs type " + vcstype)
415
416
417 def getsrclibvcs(name):
418     if name not in metadata.srclibs:
419         raise VCSException("Missing srclib " + name)
420     return metadata.srclibs[name]['Repo Type']
421
422
423 class vcs:
424
425     def __init__(self, remote, local):
426
427         # svn, git-svn and bzr may require auth
428         self.username = None
429         if self.repotype() in ('git-svn', 'bzr'):
430             if '@' in remote:
431                 if self.repotype == 'git-svn':
432                     raise VCSException("Authentication is not supported for git-svn")
433                 self.username, remote = remote.split('@')
434                 if ':' not in self.username:
435                     raise VCSException("Password required with username")
436                 self.username, self.password = self.username.split(':')
437
438         self.remote = remote
439         self.local = local
440         self.clone_failed = False
441         self.refreshed = False
442         self.srclib = None
443
444     def repotype(self):
445         return None
446
447     # Take the local repository to a clean version of the given revision, which
448     # is specificed in the VCS's native format. Beforehand, the repository can
449     # be dirty, or even non-existent. If the repository does already exist
450     # locally, it will be updated from the origin, but only once in the
451     # lifetime of the vcs object.
452     # None is acceptable for 'rev' if you know you are cloning a clean copy of
453     # the repo - otherwise it must specify a valid revision.
454     def gotorevision(self, rev):
455
456         if self.clone_failed:
457             raise VCSException("Downloading the repository already failed once, not trying again.")
458
459         # The .fdroidvcs-id file for a repo tells us what VCS type
460         # and remote that directory was created from, allowing us to drop it
461         # automatically if either of those things changes.
462         fdpath = os.path.join(self.local, '..',
463                               '.fdroidvcs-' + os.path.basename(self.local))
464         cdata = self.repotype() + ' ' + self.remote
465         writeback = True
466         deleterepo = False
467         if os.path.exists(self.local):
468             if os.path.exists(fdpath):
469                 with open(fdpath, 'r') as f:
470                     fsdata = f.read().strip()
471                 if fsdata == cdata:
472                     writeback = False
473                 else:
474                     deleterepo = True
475                     logging.info("Repository details for %s changed - deleting" % (
476                         self.local))
477             else:
478                 deleterepo = True
479                 logging.info("Repository details for %s missing - deleting" % (
480                     self.local))
481         if deleterepo:
482             shutil.rmtree(self.local)
483
484         exc = None
485
486         try:
487             self.gotorevisionx(rev)
488         except FDroidException, e:
489             exc = e
490
491         # If necessary, write the .fdroidvcs file.
492         if writeback and not self.clone_failed:
493             with open(fdpath, 'w') as f:
494                 f.write(cdata)
495
496         if exc is not None:
497             raise exc
498
499     # Derived classes need to implement this. It's called once basic checking
500     # has been performend.
501     def gotorevisionx(self, rev):
502         raise VCSException("This VCS type doesn't define gotorevisionx")
503
504     # Initialise and update submodules
505     def initsubmodules(self):
506         raise VCSException('Submodules not supported for this vcs type')
507
508     # Get a list of all known tags
509     def gettags(self):
510         if not self._gettags:
511             raise VCSException('gettags not supported for this vcs type')
512         rtags = []
513         for tag in self._gettags():
514             if re.match('[-A-Za-z0-9_. ]+$', tag):
515                 rtags.append(tag)
516         return rtags
517
518     def latesttags(self, tags, number):
519         """Get the most recent tags in a given list.
520
521         :param tags: a list of tags
522         :param number: the number to return
523         :returns: A list containing the most recent tags in the provided
524                   list, up to the maximum number given.
525         """
526         raise VCSException('latesttags not supported for this vcs type')
527
528     # Get current commit reference (hash, revision, etc)
529     def getref(self):
530         raise VCSException('getref not supported for this vcs type')
531
532     # Returns the srclib (name, path) used in setting up the current
533     # revision, or None.
534     def getsrclib(self):
535         return self.srclib
536
537
538 class vcs_git(vcs):
539
540     def repotype(self):
541         return 'git'
542
543     # If the local directory exists, but is somehow not a git repository, git
544     # will traverse up the directory tree until it finds one that is (i.e.
545     # fdroidserver) and then we'll proceed to destroy it! This is called as
546     # a safety check.
547     def checkrepo(self):
548         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
549         result = p.output.rstrip()
550         if not result.endswith(self.local):
551             raise VCSException('Repository mismatch')
552
553     def gotorevisionx(self, rev):
554         if not os.path.exists(self.local):
555             # Brand new checkout
556             p = FDroidPopen(['git', 'clone', self.remote, self.local])
557             if p.returncode != 0:
558                 self.clone_failed = True
559                 raise VCSException("Git clone failed", p.output)
560             self.checkrepo()
561         else:
562             self.checkrepo()
563             # Discard any working tree changes
564             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
565             if p.returncode != 0:
566                 raise VCSException("Git reset failed", p.output)
567             # Remove untracked files now, in case they're tracked in the target
568             # revision (it happens!)
569             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
570             if p.returncode != 0:
571                 raise VCSException("Git clean failed", p.output)
572             if not self.refreshed:
573                 # Get latest commits and tags from remote
574                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
575                 if p.returncode != 0:
576                     raise VCSException("Git fetch failed", p.output)
577                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
578                 if p.returncode != 0:
579                     raise VCSException("Git fetch failed", p.output)
580                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
581                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
582                 if p.returncode != 0:
583                     lines = p.output.splitlines()
584                     if 'Multiple remote HEAD branches' not in lines[0]:
585                         raise VCSException("Git remote set-head failed", p.output)
586                     branch = lines[1].split(' ')[-1]
587                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
588                     if p2.returncode != 0:
589                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
590                 self.refreshed = True
591         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
592         # a github repo. Most of the time this is the same as origin/master.
593         rev = rev or 'origin/HEAD'
594         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
595         if p.returncode != 0:
596             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
597         # Get rid of any uncontrolled files left behind
598         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
599         if p.returncode != 0:
600             raise VCSException("Git clean failed", p.output)
601
602     def initsubmodules(self):
603         self.checkrepo()
604         submfile = os.path.join(self.local, '.gitmodules')
605         if not os.path.isfile(submfile):
606             raise VCSException("No git submodules available")
607
608         # fix submodules not accessible without an account and public key auth
609         with open(submfile, 'r') as f:
610             lines = f.readlines()
611         with open(submfile, 'w') as f:
612             for line in lines:
613                 if 'git@github.com' in line:
614                     line = line.replace('git@github.com:', 'https://github.com/')
615                 f.write(line)
616
617         for cmd in [
618                 ['git', 'reset', '--hard'],
619                 ['git', 'clean', '-dffx'],
620                 ]:
621             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
622             if p.returncode != 0:
623                 raise VCSException("Git submodule reset failed", p.output)
624         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
625         if p.returncode != 0:
626             raise VCSException("Git submodule sync failed", p.output)
627         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
628         if p.returncode != 0:
629             raise VCSException("Git submodule update failed", p.output)
630
631     def _gettags(self):
632         self.checkrepo()
633         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
634         return p.output.splitlines()
635
636     def latesttags(self, tags, number):
637         self.checkrepo()
638         tl = []
639         for tag in tags:
640             p = FDroidPopen(
641                 ['git', 'show', '--format=format:%ct', '-s', tag],
642                 cwd=self.local, output=False)
643             # Timestamp is on the last line. For a normal tag, it's the only
644             # line, but for annotated tags, the rest of the info precedes it.
645             ts = int(p.output.splitlines()[-1])
646             tl.append((ts, tag))
647         latest = []
648         for _, t in sorted(tl)[-number:]:
649             latest.append(t)
650         return latest
651
652
653 class vcs_gitsvn(vcs):
654
655     def repotype(self):
656         return 'git-svn'
657
658     # If the local directory exists, but is somehow not a git repository, git
659     # will traverse up the directory tree until it finds one that is (i.e.
660     # fdroidserver) and then we'll proceed to destory it! This is called as
661     # a safety check.
662     def checkrepo(self):
663         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
664         result = p.output.rstrip()
665         if not result.endswith(self.local):
666             raise VCSException('Repository mismatch')
667
668     def gotorevisionx(self, rev):
669         if not os.path.exists(self.local):
670             # Brand new checkout
671             gitsvn_args = ['git', 'svn', 'clone']
672             if ';' in self.remote:
673                 remote_split = self.remote.split(';')
674                 for i in remote_split[1:]:
675                     if i.startswith('trunk='):
676                         gitsvn_args.extend(['-T', i[6:]])
677                     elif i.startswith('tags='):
678                         gitsvn_args.extend(['-t', i[5:]])
679                     elif i.startswith('branches='):
680                         gitsvn_args.extend(['-b', i[9:]])
681                 gitsvn_args.extend([remote_split[0], self.local])
682                 p = FDroidPopen(gitsvn_args, output=False)
683                 if p.returncode != 0:
684                     self.clone_failed = True
685                     raise VCSException("Git svn clone failed", p.output)
686             else:
687                 gitsvn_args.extend([self.remote, self.local])
688                 p = FDroidPopen(gitsvn_args, output=False)
689                 if p.returncode != 0:
690                     self.clone_failed = True
691                     raise VCSException("Git svn clone failed", p.output)
692             self.checkrepo()
693         else:
694             self.checkrepo()
695             # Discard any working tree changes
696             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
697             if p.returncode != 0:
698                 raise VCSException("Git reset failed", p.output)
699             # Remove untracked files now, in case they're tracked in the target
700             # revision (it happens!)
701             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
702             if p.returncode != 0:
703                 raise VCSException("Git clean failed", p.output)
704             if not self.refreshed:
705                 # Get new commits, branches and tags from repo
706                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
707                 if p.returncode != 0:
708                     raise VCSException("Git svn fetch failed")
709                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
710                 if p.returncode != 0:
711                     raise VCSException("Git svn rebase failed", p.output)
712                 self.refreshed = True
713
714         rev = rev or 'master'
715         if rev:
716             nospaces_rev = rev.replace(' ', '%20')
717             # Try finding a svn tag
718             for treeish in ['origin/', '']:
719                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
720                 if p.returncode == 0:
721                     break
722             if p.returncode != 0:
723                 # No tag found, normal svn rev translation
724                 # Translate svn rev into git format
725                 rev_split = rev.split('/')
726
727                 p = None
728                 for treeish in ['origin/', '']:
729                     if len(rev_split) > 1:
730                         treeish += rev_split[0]
731                         svn_rev = rev_split[1]
732
733                     else:
734                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
735                         treeish += 'master'
736                         svn_rev = rev
737
738                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
739
740                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
741                     git_rev = p.output.rstrip()
742
743                     if p.returncode == 0 and git_rev:
744                         break
745
746                 if p.returncode != 0 or not git_rev:
747                     # Try a plain git checkout as a last resort
748                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
749                     if p.returncode != 0:
750                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
751                 else:
752                     # Check out the git rev equivalent to the svn rev
753                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
754                     if p.returncode != 0:
755                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
756
757         # Get rid of any uncontrolled files left behind
758         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
759         if p.returncode != 0:
760             raise VCSException("Git clean failed", p.output)
761
762     def _gettags(self):
763         self.checkrepo()
764         for treeish in ['origin/', '']:
765             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
766             if os.path.isdir(d):
767                 return os.listdir(d)
768
769     def getref(self):
770         self.checkrepo()
771         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
772         if p.returncode != 0:
773             return None
774         return p.output.strip()
775
776
777 class vcs_hg(vcs):
778
779     def repotype(self):
780         return 'hg'
781
782     def gotorevisionx(self, rev):
783         if not os.path.exists(self.local):
784             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
785             if p.returncode != 0:
786                 self.clone_failed = True
787                 raise VCSException("Hg clone failed", p.output)
788         else:
789             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
790             if p.returncode != 0:
791                 raise VCSException("Hg status failed", p.output)
792             for line in p.output.splitlines():
793                 if not line.startswith('? '):
794                     raise VCSException("Unexpected output from hg status -uS: " + line)
795                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
796             if not self.refreshed:
797                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
798                 if p.returncode != 0:
799                     raise VCSException("Hg pull failed", p.output)
800                 self.refreshed = True
801
802         rev = rev or 'default'
803         if not rev:
804             return
805         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
806         if p.returncode != 0:
807             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
808         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
809         # Also delete untracked files, we have to enable purge extension for that:
810         if "'purge' is provided by the following extension" in p.output:
811             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
812                 myfile.write("\n[extensions]\nhgext.purge=\n")
813             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
814             if p.returncode != 0:
815                 raise VCSException("HG purge failed", p.output)
816         elif p.returncode != 0:
817             raise VCSException("HG purge failed", p.output)
818
819     def _gettags(self):
820         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
821         return p.output.splitlines()[1:]
822
823
824 class vcs_bzr(vcs):
825
826     def repotype(self):
827         return 'bzr'
828
829     def gotorevisionx(self, rev):
830         if not os.path.exists(self.local):
831             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
832             if p.returncode != 0:
833                 self.clone_failed = True
834                 raise VCSException("Bzr branch failed", p.output)
835         else:
836             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
837             if p.returncode != 0:
838                 raise VCSException("Bzr revert failed", p.output)
839             if not self.refreshed:
840                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
841                 if p.returncode != 0:
842                     raise VCSException("Bzr update failed", p.output)
843                 self.refreshed = True
844
845         revargs = list(['-r', rev] if rev else [])
846         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
847         if p.returncode != 0:
848             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
849
850     def _gettags(self):
851         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
852         return [tag.split('   ')[0].strip() for tag in
853                 p.output.splitlines()]
854
855
856 def retrieve_string(app_dir, string, xmlfiles=None):
857
858     res_dirs = [
859         os.path.join(app_dir, 'res'),
860         os.path.join(app_dir, 'src', 'main'),
861         ]
862
863     if xmlfiles is None:
864         xmlfiles = []
865         for res_dir in res_dirs:
866             for r, d, f in os.walk(res_dir):
867                 if os.path.basename(r) == 'values':
868                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
869
870     string_search = None
871     if string.startswith('@string/'):
872         string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
873     elif string.startswith('&') and string.endswith(';'):
874         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
875
876     if string_search is not None:
877         for xmlfile in xmlfiles:
878             if not os.path.isfile(xmlfile):
879                 continue
880             for line in file(xmlfile):
881                 matches = string_search(line)
882                 if matches:
883                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
884         return None
885
886     return string.replace("\\'", "'")
887
888
889 # Return list of existing files that will be used to find the highest vercode
890 def manifest_paths(app_dir, flavours):
891
892     possible_manifests = \
893         [os.path.join(app_dir, 'AndroidManifest.xml'),
894          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
895          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
896          os.path.join(app_dir, 'build.gradle')]
897
898     for flavour in flavours:
899         if flavour == 'yes':
900             continue
901         possible_manifests.append(
902             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
903
904     return [path for path in possible_manifests if os.path.isfile(path)]
905
906
907 # Retrieve the package name. Returns the name, or None if not found.
908 def fetch_real_name(app_dir, flavours):
909     app_search = re.compile(r'.*<application.*').search
910     name_search = re.compile(r'.*android:label="([^"]+)".*').search
911     app_found = False
912     for f in manifest_paths(app_dir, flavours):
913         if not has_extension(f, 'xml') or not os.path.isfile(f):
914             continue
915         logging.debug("fetch_real_name: Checking manifest at " + f)
916         for line in file(f):
917             if not app_found:
918                 if app_search(line):
919                     app_found = True
920             if app_found:
921                 matches = name_search(line)
922                 if matches:
923                     stringname = matches.group(1)
924                     logging.debug("fetch_real_name: using string " + stringname)
925                     result = retrieve_string(app_dir, stringname)
926                     if result:
927                         result = result.strip()
928                     return result
929     return None
930
931
932 # Retrieve the version name
933 def version_name(original, app_dir, flavours):
934     for f in manifest_paths(app_dir, flavours):
935         if not has_extension(f, 'xml'):
936             continue
937         string = retrieve_string(app_dir, original)
938         if string:
939             return string
940     return original
941
942
943 def get_library_references(root_dir):
944     libraries = []
945     proppath = os.path.join(root_dir, 'project.properties')
946     if not os.path.isfile(proppath):
947         return libraries
948     for line in file(proppath):
949         if not line.startswith('android.library.reference.'):
950             continue
951         path = line.split('=')[1].strip()
952         relpath = os.path.join(root_dir, path)
953         if not os.path.isdir(relpath):
954             continue
955         logging.debug("Found subproject at %s" % path)
956         libraries.append(path)
957     return libraries
958
959
960 def ant_subprojects(root_dir):
961     subprojects = get_library_references(root_dir)
962     for subpath in subprojects:
963         subrelpath = os.path.join(root_dir, subpath)
964         for p in get_library_references(subrelpath):
965             relp = os.path.normpath(os.path.join(subpath, p))
966             if relp not in subprojects:
967                 subprojects.insert(0, relp)
968     return subprojects
969
970
971 def remove_debuggable_flags(root_dir):
972     # Remove forced debuggable flags
973     logging.debug("Removing debuggable flags from %s" % root_dir)
974     for root, dirs, files in os.walk(root_dir):
975         if 'AndroidManifest.xml' in files:
976             path = os.path.join(root, 'AndroidManifest.xml')
977             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
978             if p.returncode != 0:
979                 raise BuildException("Failed to remove debuggable flags of %s" % path)
980
981
982 # Extract some information from the AndroidManifest.xml at the given path.
983 # Returns (version, vercode, package), any or all of which might be None.
984 # All values returned are strings.
985 def parse_androidmanifests(paths, ignoreversions=None):
986
987     if not paths:
988         return (None, None, None)
989
990     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
991     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
992     psearch = re.compile(r'.*package="([^"]+)".*').search
993
994     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
995     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
996     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
997
998     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
999
1000     max_version = None
1001     max_vercode = None
1002     max_package = None
1003
1004     for path in paths:
1005
1006         if not os.path.isfile(path):
1007             continue
1008
1009         logging.debug("Parsing manifest at {0}".format(path))
1010         gradle = has_extension(path, 'gradle')
1011         version = None
1012         vercode = None
1013         # Remember package name, may be defined separately from version+vercode
1014         package = max_package
1015
1016         for line in file(path):
1017             if not package:
1018                 if gradle:
1019                     matches = psearch_g(line)
1020                 else:
1021                     matches = psearch(line)
1022                 if matches:
1023                     package = matches.group(1)
1024             if not version:
1025                 if gradle:
1026                     matches = vnsearch_g(line)
1027                 else:
1028                     matches = vnsearch(line)
1029                 if matches:
1030                     version = matches.group(2 if gradle else 1)
1031             if not vercode:
1032                 if gradle:
1033                     matches = vcsearch_g(line)
1034                 else:
1035                     matches = vcsearch(line)
1036                 if matches:
1037                     vercode = matches.group(1)
1038
1039         logging.debug("..got package={0}, version={1}, vercode={2}"
1040                       .format(package, version, vercode))
1041
1042         # Always grab the package name and version name in case they are not
1043         # together with the highest version code
1044         if max_package is None and package is not None:
1045             max_package = package
1046         if max_version is None and version is not None:
1047             max_version = version
1048
1049         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1050             if not ignoresearch or not ignoresearch(version):
1051                 if version is not None:
1052                     max_version = version
1053                 if vercode is not None:
1054                     max_vercode = vercode
1055                 if package is not None:
1056                     max_package = package
1057             else:
1058                 max_version = "Ignore"
1059
1060     if max_version is None:
1061         max_version = "Unknown"
1062
1063     if max_package and not is_valid_package_name(max_package):
1064         raise FDroidException("Invalid package name {0}".format(max_package))
1065
1066     return (max_version, max_vercode, max_package)
1067
1068
1069 def is_valid_package_name(name):
1070     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1071
1072
1073 class FDroidException(Exception):
1074
1075     def __init__(self, value, detail=None):
1076         self.value = value
1077         self.detail = detail
1078
1079     def get_wikitext(self):
1080         ret = repr(self.value) + "\n"
1081         if self.detail:
1082             ret += "=detail=\n"
1083             ret += "<pre>\n"
1084             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1085             ret += str(txt)
1086             ret += "</pre>\n"
1087         return ret
1088
1089     def __str__(self):
1090         ret = self.value
1091         if self.detail:
1092             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1093         return ret
1094
1095
1096 class VCSException(FDroidException):
1097     pass
1098
1099
1100 class BuildException(FDroidException):
1101     pass
1102
1103
1104 # Get the specified source library.
1105 # Returns the path to it. Normally this is the path to be used when referencing
1106 # it, which may be a subdirectory of the actual project. If you want the base
1107 # directory of the project, pass 'basepath=True'.
1108 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1109               basepath=False, raw=False, prepare=True, preponly=False):
1110
1111     number = None
1112     subdir = None
1113     if raw:
1114         name = spec
1115         ref = None
1116     else:
1117         name, ref = spec.split('@')
1118         if ':' in name:
1119             number, name = name.split(':', 1)
1120         if '/' in name:
1121             name, subdir = name.split('/', 1)
1122
1123     if name not in metadata.srclibs:
1124         raise VCSException('srclib ' + name + ' not found.')
1125
1126     srclib = metadata.srclibs[name]
1127
1128     sdir = os.path.join(srclib_dir, name)
1129
1130     if not preponly:
1131         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1132         vcs.srclib = (name, number, sdir)
1133         if ref:
1134             vcs.gotorevision(ref)
1135
1136         if raw:
1137             return vcs
1138
1139     libdir = None
1140     if subdir:
1141         libdir = os.path.join(sdir, subdir)
1142     elif srclib["Subdir"]:
1143         for subdir in srclib["Subdir"]:
1144             libdir_candidate = os.path.join(sdir, subdir)
1145             if os.path.exists(libdir_candidate):
1146                 libdir = libdir_candidate
1147                 break
1148
1149     if libdir is None:
1150         libdir = sdir
1151
1152     if srclib["Srclibs"]:
1153         n = 1
1154         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1155             s_tuple = None
1156             for t in srclibpaths:
1157                 if t[0] == lib:
1158                     s_tuple = t
1159                     break
1160             if s_tuple is None:
1161                 raise VCSException('Missing recursive srclib %s for %s' % (
1162                     lib, name))
1163             place_srclib(libdir, n, s_tuple[2])
1164             n += 1
1165
1166     remove_signing_keys(sdir)
1167     remove_debuggable_flags(sdir)
1168
1169     if prepare:
1170
1171         if srclib["Prepare"]:
1172             cmd = replace_config_vars(srclib["Prepare"])
1173
1174             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1175             if p.returncode != 0:
1176                 raise BuildException("Error running prepare command for srclib %s"
1177                                      % name, p.output)
1178
1179     if basepath:
1180         libdir = sdir
1181
1182     return (name, number, libdir)
1183
1184
1185 # Prepare the source code for a particular build
1186 #  'vcs'         - the appropriate vcs object for the application
1187 #  'app'         - the application details from the metadata
1188 #  'build'       - the build details from the metadata
1189 #  'build_dir'   - the path to the build directory, usually
1190 #                   'build/app.id'
1191 #  'srclib_dir'  - the path to the source libraries directory, usually
1192 #                   'build/srclib'
1193 #  'extlib_dir'  - the path to the external libraries directory, usually
1194 #                   'build/extlib'
1195 # Returns the (root, srclibpaths) where:
1196 #   'root' is the root directory, which may be the same as 'build_dir' or may
1197 #          be a subdirectory of it.
1198 #   'srclibpaths' is information on the srclibs being used
1199 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1200
1201     # Optionally, the actual app source can be in a subdirectory
1202     if build['subdir']:
1203         root_dir = os.path.join(build_dir, build['subdir'])
1204     else:
1205         root_dir = build_dir
1206
1207     # Get a working copy of the right revision
1208     logging.info("Getting source for revision " + build['commit'])
1209     vcs.gotorevision(build['commit'])
1210
1211     # Initialise submodules if requred
1212     if build['submodules']:
1213         logging.info("Initialising submodules")
1214         vcs.initsubmodules()
1215
1216     # Check that a subdir (if we're using one) exists. This has to happen
1217     # after the checkout, since it might not exist elsewhere
1218     if not os.path.exists(root_dir):
1219         raise BuildException('Missing subdir ' + root_dir)
1220
1221     # Run an init command if one is required
1222     if build['init']:
1223         cmd = replace_config_vars(build['init'])
1224         logging.info("Running 'init' commands in %s" % root_dir)
1225
1226         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1227         if p.returncode != 0:
1228             raise BuildException("Error running init command for %s:%s" %
1229                                  (app['id'], build['version']), p.output)
1230
1231     # Apply patches if any
1232     if build['patch']:
1233         logging.info("Applying patches")
1234         for patch in build['patch']:
1235             patch = patch.strip()
1236             logging.info("Applying " + patch)
1237             patch_path = os.path.join('metadata', app['id'], patch)
1238             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1239             if p.returncode != 0:
1240                 raise BuildException("Failed to apply patch %s" % patch_path)
1241
1242     # Get required source libraries
1243     srclibpaths = []
1244     if build['srclibs']:
1245         logging.info("Collecting source libraries")
1246         for lib in build['srclibs']:
1247             srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1248                                          preponly=onserver))
1249
1250     for name, number, libpath in srclibpaths:
1251         place_srclib(root_dir, int(number) if number else None, libpath)
1252
1253     basesrclib = vcs.getsrclib()
1254     # If one was used for the main source, add that too.
1255     if basesrclib:
1256         srclibpaths.append(basesrclib)
1257
1258     # Update the local.properties file
1259     localprops = [os.path.join(build_dir, 'local.properties')]
1260     if build['subdir']:
1261         localprops += [os.path.join(root_dir, 'local.properties')]
1262     for path in localprops:
1263         props = ""
1264         if os.path.isfile(path):
1265             logging.info("Updating local.properties file at %s" % path)
1266             f = open(path, 'r')
1267             props += f.read()
1268             f.close()
1269             props += '\n'
1270         else:
1271             logging.info("Creating local.properties file at %s" % path)
1272         # Fix old-fashioned 'sdk-location' by copying
1273         # from sdk.dir, if necessary
1274         if build['oldsdkloc']:
1275             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1276                               re.S | re.M).group(1)
1277             props += "sdk-location=%s\n" % sdkloc
1278         else:
1279             props += "sdk.dir=%s\n" % config['sdk_path']
1280             props += "sdk-location=%s\n" % config['sdk_path']
1281         if build['ndk_path']:
1282             # Add ndk location
1283             props += "ndk.dir=%s\n" % build['ndk_path']
1284             props += "ndk-location=%s\n" % build['ndk_path']
1285         # Add java.encoding if necessary
1286         if build['encoding']:
1287             props += "java.encoding=%s\n" % build['encoding']
1288         f = open(path, 'w')
1289         f.write(props)
1290         f.close()
1291
1292     flavours = []
1293     if build['type'] == 'gradle':
1294         flavours = build['gradle']
1295
1296         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1297         gradlepluginver = None
1298
1299         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1300
1301         # Parent dir build.gradle
1302         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1303         if parent_dir.startswith(build_dir):
1304             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1305
1306         for path in gradle_files:
1307             if gradlepluginver:
1308                 break
1309             if not os.path.isfile(path):
1310                 continue
1311             with open(path) as f:
1312                 for line in f:
1313                     match = version_regex.match(line)
1314                     if match:
1315                         gradlepluginver = match.group(1)
1316                         break
1317
1318         if gradlepluginver:
1319             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1320         else:
1321             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1322             build['gradlepluginver'] = LooseVersion('0.11')
1323
1324         if build['target']:
1325             n = build["target"].split('-')[1]
1326             FDroidPopen(['sed', '-i',
1327                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1328                          'build.gradle'], cwd=root_dir, output=False)
1329
1330     # Remove forced debuggable flags
1331     remove_debuggable_flags(root_dir)
1332
1333     # Insert version code and number into the manifest if necessary
1334     if build['forceversion']:
1335         logging.info("Changing the version name")
1336         for path in manifest_paths(root_dir, flavours):
1337             if not os.path.isfile(path):
1338                 continue
1339             if has_extension(path, 'xml'):
1340                 p = FDroidPopen(['sed', '-i',
1341                                  's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1342                                  path], output=False)
1343                 if p.returncode != 0:
1344                     raise BuildException("Failed to amend manifest")
1345             elif has_extension(path, 'gradle'):
1346                 p = FDroidPopen(['sed', '-i',
1347                                  's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1348                                  path], output=False)
1349                 if p.returncode != 0:
1350                     raise BuildException("Failed to amend build.gradle")
1351     if build['forcevercode']:
1352         logging.info("Changing the version code")
1353         for path in manifest_paths(root_dir, flavours):
1354             if not os.path.isfile(path):
1355                 continue
1356             if has_extension(path, 'xml'):
1357                 p = FDroidPopen(['sed', '-i',
1358                                  's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1359                                  path], output=False)
1360                 if p.returncode != 0:
1361                     raise BuildException("Failed to amend manifest")
1362             elif has_extension(path, 'gradle'):
1363                 p = FDroidPopen(['sed', '-i',
1364                                  's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1365                                  path], output=False)
1366                 if p.returncode != 0:
1367                     raise BuildException("Failed to amend build.gradle")
1368
1369     # Delete unwanted files
1370     if build['rm']:
1371         logging.info("Removing specified files")
1372         for part in getpaths(build_dir, build, 'rm'):
1373             dest = os.path.join(build_dir, part)
1374             logging.info("Removing {0}".format(part))
1375             if os.path.lexists(dest):
1376                 if os.path.islink(dest):
1377                     FDroidPopen(['unlink', dest], output=False)
1378                 else:
1379                     FDroidPopen(['rm', '-rf', dest], output=False)
1380             else:
1381                 logging.info("...but it didn't exist")
1382
1383     remove_signing_keys(build_dir)
1384
1385     # Add required external libraries
1386     if build['extlibs']:
1387         logging.info("Collecting prebuilt libraries")
1388         libsdir = os.path.join(root_dir, 'libs')
1389         if not os.path.exists(libsdir):
1390             os.mkdir(libsdir)
1391         for lib in build['extlibs']:
1392             lib = lib.strip()
1393             logging.info("...installing extlib {0}".format(lib))
1394             libf = os.path.basename(lib)
1395             libsrc = os.path.join(extlib_dir, lib)
1396             if not os.path.exists(libsrc):
1397                 raise BuildException("Missing extlib file {0}".format(libsrc))
1398             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1399
1400     # Run a pre-build command if one is required
1401     if build['prebuild']:
1402         logging.info("Running 'prebuild' commands in %s" % root_dir)
1403
1404         cmd = replace_config_vars(build['prebuild'])
1405
1406         # Substitute source library paths into prebuild commands
1407         for name, number, libpath in srclibpaths:
1408             libpath = os.path.relpath(libpath, root_dir)
1409             cmd = cmd.replace('$$' + name + '$$', libpath)
1410
1411         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1412         if p.returncode != 0:
1413             raise BuildException("Error running prebuild command for %s:%s" %
1414                                  (app['id'], build['version']), p.output)
1415
1416     # Generate (or update) the ant build file, build.xml...
1417     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1418         parms = ['android', 'update', 'lib-project']
1419         lparms = ['android', 'update', 'project']
1420
1421         if build['target']:
1422             parms += ['-t', build['target']]
1423             lparms += ['-t', build['target']]
1424         if build['update'] == ['auto']:
1425             update_dirs = ant_subprojects(root_dir) + ['.']
1426         else:
1427             update_dirs = build['update']
1428
1429         for d in update_dirs:
1430             subdir = os.path.join(root_dir, d)
1431             if d == '.':
1432                 logging.debug("Updating main project")
1433                 cmd = parms + ['-p', d]
1434             else:
1435                 logging.debug("Updating subproject %s" % d)
1436                 cmd = lparms + ['-p', d]
1437             p = SdkToolsPopen(cmd, cwd=root_dir)
1438             # Check to see whether an error was returned without a proper exit
1439             # code (this is the case for the 'no target set or target invalid'
1440             # error)
1441             if p.returncode != 0 or p.output.startswith("Error: "):
1442                 raise BuildException("Failed to update project at %s" % d, p.output)
1443             # Clean update dirs via ant
1444             if d != '.':
1445                 logging.info("Cleaning subproject %s" % d)
1446                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1447
1448     return (root_dir, srclibpaths)
1449
1450
1451 # Split and extend via globbing the paths from a field
1452 def getpaths(build_dir, build, field):
1453     paths = []
1454     for p in build[field]:
1455         p = p.strip()
1456         full_path = os.path.join(build_dir, p)
1457         full_path = os.path.normpath(full_path)
1458         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1459     return paths
1460
1461
1462 # Scan the source code in the given directory (and all subdirectories)
1463 # and return the number of fatal problems encountered
1464 def scan_source(build_dir, root_dir, thisbuild):
1465
1466     count = 0
1467
1468     # Common known non-free blobs (always lower case):
1469     usual_suspects = [
1470         re.compile(r'flurryagent', re.IGNORECASE),
1471         re.compile(r'paypal.*mpl', re.IGNORECASE),
1472         re.compile(r'google.*analytics', re.IGNORECASE),
1473         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1474         re.compile(r'google.*ad.*view', re.IGNORECASE),
1475         re.compile(r'google.*admob', re.IGNORECASE),
1476         re.compile(r'google.*play.*services', re.IGNORECASE),
1477         re.compile(r'crittercism', re.IGNORECASE),
1478         re.compile(r'heyzap', re.IGNORECASE),
1479         re.compile(r'jpct.*ae', re.IGNORECASE),
1480         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1481         re.compile(r'bugsense', re.IGNORECASE),
1482         re.compile(r'crashlytics', re.IGNORECASE),
1483         re.compile(r'ouya.*sdk', re.IGNORECASE),
1484         re.compile(r'libspen23', re.IGNORECASE),
1485     ]
1486
1487     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1488     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1489
1490     scanignore_worked = set()
1491     scandelete_worked = set()
1492
1493     try:
1494         ms = magic.open(magic.MIME_TYPE)
1495         ms.load()
1496     except AttributeError:
1497         ms = None
1498
1499     def toignore(fd):
1500         for p in scanignore:
1501             if fd.startswith(p):
1502                 scanignore_worked.add(p)
1503                 return True
1504         return False
1505
1506     def todelete(fd):
1507         for p in scandelete:
1508             if fd.startswith(p):
1509                 scandelete_worked.add(p)
1510                 return True
1511         return False
1512
1513     def ignoreproblem(what, fd, fp):
1514         logging.info('Ignoring %s at %s' % (what, fd))
1515         return 0
1516
1517     def removeproblem(what, fd, fp):
1518         logging.info('Removing %s at %s' % (what, fd))
1519         os.remove(fp)
1520         return 0
1521
1522     def warnproblem(what, fd):
1523         logging.warn('Found %s at %s' % (what, fd))
1524
1525     def handleproblem(what, fd, fp):
1526         if toignore(fd):
1527             return ignoreproblem(what, fd, fp)
1528         if todelete(fd):
1529             return removeproblem(what, fd, fp)
1530         logging.error('Found %s at %s' % (what, fd))
1531         return 1
1532
1533     # Iterate through all files in the source code
1534     for r, d, f in os.walk(build_dir, topdown=True):
1535
1536         # It's topdown, so checking the basename is enough
1537         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1538             if ignoredir in d:
1539                 d.remove(ignoredir)
1540
1541         for curfile in f:
1542
1543             # Path (relative) to the file
1544             fp = os.path.join(r, curfile)
1545             fd = fp[len(build_dir) + 1:]
1546
1547             try:
1548                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1549             except UnicodeError:
1550                 warnproblem('malformed magic number', fd)
1551
1552             if mime == 'application/x-sharedlib':
1553                 count += handleproblem('shared library', fd, fp)
1554
1555             elif mime == 'application/x-archive':
1556                 count += handleproblem('static library', fd, fp)
1557
1558             elif mime == 'application/x-executable':
1559                 count += handleproblem('binary executable', fd, fp)
1560
1561             elif mime == 'application/x-java-applet':
1562                 count += handleproblem('Java compiled class', fd, fp)
1563
1564             elif mime in (
1565                     'application/jar',
1566                     'application/zip',
1567                     'application/java-archive',
1568                     'application/octet-stream',
1569                     'binary', ):
1570
1571                 if has_extension(fp, 'apk'):
1572                     removeproblem('APK file', fd, fp)
1573
1574                 elif has_extension(fp, 'jar'):
1575
1576                     if any(suspect.match(curfile) for suspect in usual_suspects):
1577                         count += handleproblem('usual supect', fd, fp)
1578                     else:
1579                         warnproblem('JAR file', fd)
1580
1581                 elif has_extension(fp, 'zip'):
1582                     warnproblem('ZIP file', fd)
1583
1584                 else:
1585                     warnproblem('unknown compressed or binary file', fd)
1586
1587             elif has_extension(fp, 'java') and os.path.isfile(fp):
1588                 for line in file(fp):
1589                     if 'DexClassLoader' in line:
1590                         count += handleproblem('DexClassLoader', fd, fp)
1591                         break
1592     if ms is not None:
1593         ms.close()
1594
1595     for p in scanignore:
1596         if p not in scanignore_worked:
1597             logging.error('Unused scanignore path: %s' % p)
1598             count += 1
1599
1600     for p in scandelete:
1601         if p not in scandelete_worked:
1602             logging.error('Unused scandelete path: %s' % p)
1603             count += 1
1604
1605     # Presence of a jni directory without buildjni=yes might
1606     # indicate a problem (if it's not a problem, explicitly use
1607     # buildjni=no to bypass this check)
1608     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1609             not thisbuild['buildjni']):
1610         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1611         count += 1
1612
1613     return count
1614
1615
1616 class KnownApks:
1617
1618     def __init__(self):
1619         self.path = os.path.join('stats', 'known_apks.txt')
1620         self.apks = {}
1621         if os.path.isfile(self.path):
1622             for line in file(self.path):
1623                 t = line.rstrip().split(' ')
1624                 if len(t) == 2:
1625                     self.apks[t[0]] = (t[1], None)
1626                 else:
1627                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1628         self.changed = False
1629
1630     def writeifchanged(self):
1631         if self.changed:
1632             if not os.path.exists('stats'):
1633                 os.mkdir('stats')
1634             f = open(self.path, 'w')
1635             lst = []
1636             for apk, app in self.apks.iteritems():
1637                 appid, added = app
1638                 line = apk + ' ' + appid
1639                 if added:
1640                     line += ' ' + time.strftime('%Y-%m-%d', added)
1641                 lst.append(line)
1642             for line in sorted(lst):
1643                 f.write(line + '\n')
1644             f.close()
1645
1646     # Record an apk (if it's new, otherwise does nothing)
1647     # Returns the date it was added.
1648     def recordapk(self, apk, app):
1649         if apk not in self.apks:
1650             self.apks[apk] = (app, time.gmtime(time.time()))
1651             self.changed = True
1652         _, added = self.apks[apk]
1653         return added
1654
1655     # Look up information - given the 'apkname', returns (app id, date added/None).
1656     # Or returns None for an unknown apk.
1657     def getapp(self, apkname):
1658         if apkname in self.apks:
1659             return self.apks[apkname]
1660         return None
1661
1662     # Get the most recent 'num' apps added to the repo, as a list of package ids
1663     # with the most recent first.
1664     def getlatest(self, num):
1665         apps = {}
1666         for apk, app in self.apks.iteritems():
1667             appid, added = app
1668             if added:
1669                 if appid in apps:
1670                     if apps[appid] > added:
1671                         apps[appid] = added
1672                 else:
1673                     apps[appid] = added
1674         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1675         lst = [app for app, _ in sortedapps]
1676         lst.reverse()
1677         return lst
1678
1679
1680 def isApkDebuggable(apkfile, config):
1681     """Returns True if the given apk file is debuggable
1682
1683     :param apkfile: full path to the apk to check"""
1684
1685     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1686                       output=False)
1687     if p.returncode != 0:
1688         logging.critical("Failed to get apk manifest information")
1689         sys.exit(1)
1690     for line in p.output.splitlines():
1691         if 'android:debuggable' in line and not line.endswith('0x0'):
1692             return True
1693     return False
1694
1695
1696 class AsynchronousFileReader(threading.Thread):
1697
1698     '''
1699     Helper class to implement asynchronous reading of a file
1700     in a separate thread. Pushes read lines on a queue to
1701     be consumed in another thread.
1702     '''
1703
1704     def __init__(self, fd, queue):
1705         assert isinstance(queue, Queue.Queue)
1706         assert callable(fd.readline)
1707         threading.Thread.__init__(self)
1708         self._fd = fd
1709         self._queue = queue
1710
1711     def run(self):
1712         '''The body of the tread: read lines and put them on the queue.'''
1713         for line in iter(self._fd.readline, ''):
1714             self._queue.put(line)
1715
1716     def eof(self):
1717         '''Check whether there is no more content to expect.'''
1718         return not self.is_alive() and self._queue.empty()
1719
1720
1721 class PopenResult:
1722     returncode = None
1723     output = ''
1724
1725
1726 def SdkToolsPopen(commands, cwd=None, output=True):
1727     cmd = commands[0]
1728     if cmd not in config:
1729         config[cmd] = find_sdk_tools_cmd(commands[0])
1730     return FDroidPopen([config[cmd]] + commands[1:],
1731                        cwd=cwd, output=output)
1732
1733
1734 def FDroidPopen(commands, cwd=None, output=True):
1735     """
1736     Run a command and capture the possibly huge output.
1737
1738     :param commands: command and argument list like in subprocess.Popen
1739     :param cwd: optionally specifies a working directory
1740     :returns: A PopenResult.
1741     """
1742
1743     global env
1744
1745     if cwd:
1746         cwd = os.path.normpath(cwd)
1747         logging.debug("Directory: %s" % cwd)
1748     logging.debug("> %s" % ' '.join(commands))
1749
1750     result = PopenResult()
1751     p = None
1752     try:
1753         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1754                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1755     except OSError, e:
1756         raise BuildException("OSError while trying to execute " +
1757                              ' '.join(commands) + ': ' + str(e))
1758
1759     stdout_queue = Queue.Queue()
1760     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1761     stdout_reader.start()
1762
1763     # Check the queue for output (until there is no more to get)
1764     while not stdout_reader.eof():
1765         while not stdout_queue.empty():
1766             line = stdout_queue.get()
1767             if output and options.verbose:
1768                 # Output directly to console
1769                 sys.stderr.write(line)
1770                 sys.stderr.flush()
1771             result.output += line
1772
1773         time.sleep(0.1)
1774
1775     result.returncode = p.wait()
1776     return result
1777
1778
1779 def remove_signing_keys(build_dir):
1780     comment = re.compile(r'[ ]*//')
1781     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1782     line_matches = [
1783         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1784         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1785         re.compile(r'.*variant\.outputFile = .*'),
1786         re.compile(r'.*output\.outputFile = .*'),
1787         re.compile(r'.*\.readLine\(.*'),
1788     ]
1789     for root, dirs, files in os.walk(build_dir):
1790         if 'build.gradle' in files:
1791             path = os.path.join(root, 'build.gradle')
1792
1793             with open(path, "r") as o:
1794                 lines = o.readlines()
1795
1796             changed = False
1797
1798             opened = 0
1799             i = 0
1800             with open(path, "w") as o:
1801                 while i < len(lines):
1802                     line = lines[i]
1803                     i += 1
1804                     while line.endswith('\\\n'):
1805                         line = line.rstrip('\\\n') + lines[i]
1806                         i += 1
1807
1808                     if comment.match(line):
1809                         continue
1810
1811                     if opened > 0:
1812                         opened += line.count('{')
1813                         opened -= line.count('}')
1814                         continue
1815
1816                     if signing_configs.match(line):
1817                         changed = True
1818                         opened += 1
1819                         continue
1820
1821                     if any(s.match(line) for s in line_matches):
1822                         changed = True
1823                         continue
1824
1825                     if opened == 0:
1826                         o.write(line)
1827
1828             if changed:
1829                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1830
1831         for propfile in [
1832                 'project.properties',
1833                 'build.properties',
1834                 'default.properties',
1835                 'ant.properties', ]:
1836             if propfile in files:
1837                 path = os.path.join(root, propfile)
1838
1839                 with open(path, "r") as o:
1840                     lines = o.readlines()
1841
1842                 changed = False
1843
1844                 with open(path, "w") as o:
1845                     for line in lines:
1846                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1847                             changed = True
1848                             continue
1849
1850                         o.write(line)
1851
1852                 if changed:
1853                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1854
1855
1856 def reset_env_path():
1857     global env, orig_path
1858     env['PATH'] = orig_path
1859
1860
1861 def add_to_env_path(path):
1862     global env
1863     paths = env['PATH'].split(os.pathsep)
1864     if path in paths:
1865         return
1866     paths.append(path)
1867     env['PATH'] = os.pathsep.join(paths)
1868
1869
1870 def replace_config_vars(cmd):
1871     global env
1872     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1873     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1874     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1875     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1876     return cmd
1877
1878
1879 def place_srclib(root_dir, number, libpath):
1880     if not number:
1881         return
1882     relpath = os.path.relpath(libpath, root_dir)
1883     proppath = os.path.join(root_dir, 'project.properties')
1884
1885     lines = []
1886     if not os.path.isfile(proppath):
1887         return
1888
1889     with open(proppath, "r") as o:
1890         lines = o.readlines()
1891
1892     with open(proppath, "w") as o:
1893         placed = False
1894         for line in lines:
1895             if line.startswith('android.library.reference.%d=' % number):
1896                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1897                 placed = True
1898             else:
1899                 o.write(line)
1900         if not placed:
1901             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1902
1903
1904 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1905     """Verify that two apks are the same
1906
1907     One of the inputs is signed, the other is unsigned. The signature metadata
1908     is transferred from the signed to the unsigned apk, and then jarsigner is
1909     used to verify that the signature from the signed apk is also varlid for
1910     the unsigned one.
1911     :param signed_apk: Path to a signed apk file
1912     :param unsigned_apk: Path to an unsigned apk file expected to match it
1913     :param tmp_dir: Path to directory for temporary files
1914     :returns: None if the verification is successful, otherwise a string
1915               describing what went wrong.
1916     """
1917     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1918     with ZipFile(signed_apk) as signed_apk_as_zip:
1919         meta_inf_files = ['META-INF/MANIFEST.MF']
1920         for f in signed_apk_as_zip.namelist():
1921             if sigfile.match(f):
1922                 meta_inf_files.append(f)
1923         if len(meta_inf_files) < 3:
1924             return "Signature files missing from {0}".format(signed_apk)
1925         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1926     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1927         for meta_inf_file in meta_inf_files:
1928             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1929
1930     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1931         logging.info("...NOT verified - {0}".format(signed_apk))
1932         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1933     logging.info("...successfully verified")
1934     return None
1935
1936
1937 def compare_apks(apk1, apk2, tmp_dir):
1938     """Compare two apks
1939
1940     Returns None if the apk content is the same (apart from the signing key),
1941     otherwise a string describing what's different, or what went wrong when
1942     trying to do the comparison.
1943     """
1944
1945     badchars = re.compile('''[/ :;'"]''')
1946     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1947     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1948     for d in [apk1dir, apk2dir]:
1949         if os.path.exists(d):
1950             shutil.rmtree(d)
1951         os.mkdir(d)
1952         os.mkdir(os.path.join(d, 'jar-xf'))
1953
1954     if subprocess.call(['jar', 'xf',
1955                         os.path.abspath(apk1)],
1956                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1957         return("Failed to unpack " + apk1)
1958     if subprocess.call(['jar', 'xf',
1959                         os.path.abspath(apk2)],
1960                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1961         return("Failed to unpack " + apk2)
1962
1963     # try to find apktool in the path, if it hasn't been manually configed
1964     if 'apktool' not in config:
1965         tmp = find_command('apktool')
1966         if tmp is not None:
1967             config['apktool'] = tmp
1968     if 'apktool' in config:
1969         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1970                            cwd=apk1dir) != 0:
1971             return("Failed to unpack " + apk1)
1972         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1973                            cwd=apk2dir) != 0:
1974             return("Failed to unpack " + apk2)
1975
1976     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1977     lines = p.output.splitlines()
1978     if len(lines) != 1 or 'META-INF' not in lines[0]:
1979         meld = find_command('meld')
1980         if meld is not None:
1981             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1982         return("Unexpected diff output - " + p.output)
1983
1984     # since everything verifies, delete the comparison to keep cruft down
1985     shutil.rmtree(apk1dir)
1986     shutil.rmtree(apk2dir)
1987
1988     # If we get here, it seems like they're the same!
1989     return None
1990
1991
1992 def find_command(command):
1993     '''find the full path of a command, or None if it can't be found in the PATH'''
1994
1995     def is_exe(fpath):
1996         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1997
1998     fpath, fname = os.path.split(command)
1999     if fpath:
2000         if is_exe(command):
2001             return command
2002     else:
2003         for path in os.environ["PATH"].split(os.pathsep):
2004             path = path.strip('"')
2005             exe_file = os.path.join(path, command)
2006             if is_exe(exe_file):
2007                 return exe_file
2008
2009     return None