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