chiark / gitweb /
Fix a few build-time logging levels
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import json
30 from ConfigParser import ConfigParser
31 from optparse import OptionParser, OptionError
32 import logging
33 import multiprocessing
34
35 import common, metadata
36 from common import BuildException, VCSException, FDroidPopen, SilentPopen
37
38 def get_builder_vm_id():
39     vd = os.path.join('builder', '.vagrant')
40     if os.path.isdir(vd):
41         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
42         with open(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')) as vf:
43             id = vf.read()
44         return id
45     else:
46         # Vagrant 1.0 - it's a json file...
47         with open(os.path.join('builder', '.vagrant')) as vf:
48             v = json.load(vf)
49         return v['active']['default']
50
51 def got_valid_builder_vm():
52     """Returns True if we have a valid-looking builder vm
53     """
54     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
55         return False
56     vd = os.path.join('builder', '.vagrant')
57     if not os.path.exists(vd):
58         return False
59     if not os.path.isdir(vd):
60         # Vagrant 1.0 - if the directory is there, it's valid...
61         return True
62     # Vagrant 1.2 - the directory can exist, but the id can be missing...
63     if not os.path.exists(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')):
64         return False
65     return True
66
67
68 def vagrant(params, cwd=None, printout=False):
69     """Run vagrant.
70
71     :param: list of parameters to pass to vagrant
72     :cwd: directory to run in, or None for current directory
73     :returns: (ret, out) where ret is the return code, and out
74                is the stdout (and stderr) from vagrant
75     """
76     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
77     return (p.returncode, p.stdout)
78
79
80 # Note that 'force' here also implies test mode.
81 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
82     """Do a build on the build server."""
83
84     import ssh
85     if options.verbose:
86         logging.getLogger("ssh").setLevel(logging.DEBUG)
87     else:
88         logging.getLogger("ssh").setLevel(logging.WARN)
89
90     # Reset existing builder machine to a clean state if possible.
91     vm_ok = False
92     if not options.resetserver:
93         logging.info("Checking for valid existing build server")
94
95         if got_valid_builder_vm():
96             logging.info("...VM is present")
97             p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'], cwd='builder')
98             if 'fdroidclean' in p.stdout:
99                 logging.info("...snapshot exists - resetting build server to clean state")
100                 retcode, output = vagrant(['status'], cwd='builder')
101
102                 if 'running' in output:
103                     logging.info("...suspending")
104                     vagrant(['suspend'], cwd='builder')
105                     logging.info("...waiting a sec...")
106                     time.sleep(10)
107                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'restore', 'fdroidclean'],
108                     cwd='builder')
109
110                 if p.returncode == 0:
111                     logging.info("...reset to snapshot - server is valid")
112                     retcode, output = vagrant(['up'], cwd='builder')
113                     if retcode != 0:
114                         raise BuildException("Failed to start build server")
115                     logging.info("...waiting a sec...")
116                     time.sleep(10)
117                     vm_ok = True
118                 else:
119                     logging.info("...failed to reset to snapshot")
120             else:
121                 logging.info("...snapshot doesn't exist - VBoxManage snapshot list:\n" + output)
122
123     # If we can't use the existing machine for any reason, make a
124     # new one from scratch.
125     if not vm_ok:
126         if os.path.exists('builder'):
127             logging.info("Removing broken/incomplete/unwanted build server")
128             vagrant(['destroy', '-f'], cwd='builder')
129             shutil.rmtree('builder')
130         os.mkdir('builder')
131
132         p = subprocess.Popen('vagrant --version', shell=True, stdout=subprocess.PIPE)
133         vver = p.communicate()[0]
134         if vver.startswith('Vagrant version 1.2'):
135             with open('builder/Vagrantfile', 'w') as vf:
136                 vf.write('Vagrant.configure("2") do |config|\n')
137                 vf.write('config.vm.box = "buildserver"\n')
138                 vf.write('end\n')
139         else:
140             with open('builder/Vagrantfile', 'w') as vf:
141                 vf.write('Vagrant::Config.run do |config|\n')
142                 vf.write('config.vm.box = "buildserver"\n')
143                 vf.write('end\n')
144
145         logging.info("Starting new build server")
146         retcode, _ = vagrant(['up'], cwd='builder')
147         if retcode != 0:
148             raise BuildException("Failed to start build server")
149
150         # Open SSH connection to make sure it's working and ready...
151         logging.info("Connecting to virtual machine...")
152         if subprocess.call('vagrant ssh-config >sshconfig',
153                 cwd='builder', shell=True) != 0:
154             raise BuildException("Error getting ssh config")
155         vagranthost = 'default' # Host in ssh config file
156         sshconfig = ssh.SSHConfig()
157         sshf = open('builder/sshconfig', 'r')
158         sshconfig.parse(sshf)
159         sshf.close()
160         sshconfig = sshconfig.lookup(vagranthost)
161         sshs = ssh.SSHClient()
162         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
163         idfile = sshconfig['identityfile']
164         if idfile.startswith('"') and idfile.endswith('"'):
165             idfile = idfile[1:-1]
166         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
167             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
168             key_filename=idfile)
169         sshs.close()
170
171         logging.info("Saving clean state of new build server")
172         retcode, _ = vagrant(['suspend'], cwd='builder')
173         if retcode != 0:
174             raise BuildException("Failed to suspend build server")
175         logging.info("...waiting a sec...")
176         time.sleep(10)
177         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'take', 'fdroidclean'],
178                 cwd='builder')
179         if p.returncode != 0:
180             raise BuildException("Failed to take snapshot")
181         logging.info("...waiting a sec...")
182         time.sleep(10)
183         logging.info("Restarting new build server")
184         retcode, _ = vagrant(['up'], cwd='builder')
185         if retcode != 0:
186             raise BuildException("Failed to start build server")
187         logging.info("...waiting a sec...")
188         time.sleep(10)
189         # Make sure it worked...
190         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'],
191             cwd='builder')
192         if 'fdroidclean' not in p.stdout:
193             raise BuildException("Failed to take snapshot.")
194
195     try:
196
197         # Get SSH configuration settings for us to connect...
198         logging.info("Getting ssh configuration...")
199         subprocess.call('vagrant ssh-config >sshconfig',
200                 cwd='builder', shell=True)
201         vagranthost = 'default' # Host in ssh config file
202
203         # Load and parse the SSH config...
204         sshconfig = ssh.SSHConfig()
205         sshf = open('builder/sshconfig', 'r')
206         sshconfig.parse(sshf)
207         sshf.close()
208         sshconfig = sshconfig.lookup(vagranthost)
209
210         # Open SSH connection...
211         logging.info("Connecting to virtual machine...")
212         sshs = ssh.SSHClient()
213         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
214         idfile = sshconfig['identityfile']
215         if idfile.startswith('"') and idfile.endswith('"'):
216             idfile = idfile[1:-1]
217         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
218             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
219             key_filename=idfile)
220
221         # Get an SFTP connection...
222         ftp = sshs.open_sftp()
223         ftp.get_channel().settimeout(15)
224
225         # Put all the necessary files in place...
226         ftp.chdir('/home/vagrant')
227
228         # Helper to copy the contents of a directory to the server...
229         def send_dir(path):
230             root = os.path.dirname(path)
231             main = os.path.basename(path)
232             ftp.mkdir(main)
233             for r, d, f in os.walk(path):
234                 rr = os.path.relpath(r, root)
235                 ftp.chdir(rr)
236                 for dd in d:
237                     ftp.mkdir(dd)
238                 for ff in f:
239                     lfile = os.path.join(root, rr, ff)
240                     if not os.path.islink(lfile):
241                         ftp.put(lfile, ff)
242                         ftp.chmod(ff, os.stat(lfile).st_mode)
243                 for i in range(len(rr.split('/'))):
244                     ftp.chdir('..')
245             ftp.chdir('..')
246
247         logging.info("Preparing server for build...")
248         serverpath = os.path.abspath(os.path.dirname(__file__))
249         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
250         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
251         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
252         ftp.put(os.path.join(serverpath, '..', 'buildserver',
253             'config.buildserver.py'), 'config.py')
254         ftp.chmod('config.py', 0o600)
255
256         # Copy the metadata - just the file for this app...
257         ftp.mkdir('metadata')
258         ftp.mkdir('srclibs')
259         ftp.chdir('metadata')
260         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
261                 app['id'] + '.txt')
262         # And patches if there are any...
263         if os.path.exists(os.path.join('metadata', app['id'])):
264             send_dir(os.path.join('metadata', app['id']))
265
266         ftp.chdir('/home/vagrant')
267         # Create the build directory...
268         ftp.mkdir('build')
269         ftp.chdir('build')
270         ftp.mkdir('extlib')
271         ftp.mkdir('srclib')
272         # Copy any extlibs that are required...
273         if 'extlibs' in thisbuild:
274             ftp.chdir('/home/vagrant/build/extlib')
275             for lib in thisbuild['extlibs']:
276                 lib = lib.strip()
277                 libsrc = os.path.join('build/extlib', lib)
278                 if not os.path.exists(libsrc):
279                     raise BuildException("Missing extlib {0}".format(libsrc))
280                 lp = lib.split('/')
281                 for d in lp[:-1]:
282                     if d not in ftp.listdir():
283                         ftp.mkdir(d)
284                     ftp.chdir(d)
285                 ftp.put(libsrc, lp[-1])
286                 for _ in lp[:-1]:
287                     ftp.chdir('..')
288         # Copy any srclibs that are required...
289         srclibpaths = []
290         if 'srclibs' in thisbuild:
291             for lib in thisbuild['srclibs']:
292                 srclibpaths.append(common.getsrclib(lib, 'build/srclib', srclibpaths,
293                     basepath=True, prepare=False))
294
295         # If one was used for the main source, add that too.
296         basesrclib = vcs.getsrclib()
297         if basesrclib:
298             srclibpaths.append(basesrclib)
299         for name, number, lib in srclibpaths:
300             logging.info("Sending srclib '%s'" % lib)
301             ftp.chdir('/home/vagrant/build/srclib')
302             if not os.path.exists(lib):
303                 raise BuildException("Missing srclib directory '" + lib + "'")
304             fv = '.fdroidvcs-' + name
305             ftp.put(os.path.join('build/srclib', fv), fv)
306             send_dir(lib)
307             # Copy the metadata file too...
308             ftp.chdir('/home/vagrant/srclibs')
309             ftp.put(os.path.join('srclibs', name + '.txt'),
310                     name + '.txt')
311         # Copy the main app source code
312         # (no need if it's a srclib)
313         if (not basesrclib) and os.path.exists(build_dir):
314             ftp.chdir('/home/vagrant/build')
315             fv = '.fdroidvcs-' + app['id']
316             ftp.put(os.path.join('build', fv), fv)
317             send_dir(build_dir)
318
319         # Execute the build script...
320         logging.info("Starting build...")
321         chan = sshs.get_transport().open_session()
322         chan.get_pty()
323         cmdline = 'python build.py --on-server'
324         if force:
325             cmdline += ' --force --test'
326         if options.verbose:
327             cmdline += ' --verbose'
328         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
329         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
330         output = ''
331         while not chan.exit_status_ready():
332             while chan.recv_ready():
333                 output += chan.recv(1024)
334             time.sleep(0.1)
335         logging.info("...getting exit status")
336         returncode = chan.recv_exit_status()
337         while True:
338             get = chan.recv(1024)
339             if len(get) == 0:
340                 break
341             output += get
342         if returncode != 0:
343             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output)
344
345         # Retrieve the built files...
346         logging.info("Retrieving build output...")
347         if force:
348             ftp.chdir('/home/vagrant/tmp')
349         else:
350             ftp.chdir('/home/vagrant/unsigned')
351         apkfile = common.getapkname(app,thisbuild)
352         tarball = common.getsrcname(app,thisbuild)
353         try:
354             ftp.get(apkfile, os.path.join(output_dir, apkfile))
355             if not options.notarball:
356                 ftp.get(tarball, os.path.join(output_dir, tarball))
357         except:
358             raise BuildException("Build failed for %s:%s - missing output files" % (app['id'], thisbuild['version']), output)
359         ftp.close()
360
361     finally:
362
363         # Suspend the build server.
364         logging.info("Suspending build server")
365         subprocess.call(['vagrant', 'suspend'], cwd='builder')
366
367 def adapt_gradle(build_dir):
368     for root, dirs, files in os.walk(build_dir):
369         if 'build.gradle' in files:
370             path = os.path.join(root, 'build.gradle')
371             logging.info("Adapting build.gradle at %s" % path)
372
373             FDroidPopen(['sed', '-i',
374                     r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
375                     + config['build_tools'] + '"@g', path])
376
377
378 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
379     """Do a build locally."""
380
381     if thisbuild.get('buildjni') not in (None, ['no']):
382         if not config['ndk_path']:
383             logging.critical("$ANDROID_NDK is not set!")
384             sys.exit(3)
385         elif not os.path.isdir(config['sdk_path']):
386             logging.critical("$ANDROID_NDK points to a non-existing directory!")
387             sys.exit(3)
388
389     # Prepare the source code...
390     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
391             build_dir, srclib_dir, extlib_dir, onserver)
392
393     # We need to clean via the build tool in case the binary dirs are
394     # different from the default ones
395     p = None
396     if thisbuild['type'] == 'maven':
397         logging.info("Cleaning Maven project...")
398         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
399
400         if '@' in thisbuild['maven']:
401             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
402             maven_dir = os.path.normpath(maven_dir)
403         else:
404             maven_dir = root_dir
405
406         p = FDroidPopen(cmd, cwd=maven_dir)
407
408     elif thisbuild['type'] == 'gradle':
409
410         logging.info("Cleaning Gradle project...")
411         cmd = [config['gradle'], 'clean']
412
413         if '@' in thisbuild['gradle']:
414             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@',1)[1])
415             gradle_dir = os.path.normpath(gradle_dir)
416         else:
417             gradle_dir = root_dir
418
419         adapt_gradle(build_dir)
420         for name, number, libpath in srclibpaths:
421             adapt_gradle(libpath)
422
423         p = FDroidPopen(cmd, cwd=gradle_dir)
424
425     elif thisbuild['type'] == 'kivy':
426         pass
427
428     elif thisbuild['type'] == 'ant':
429         logging.info("Cleaning Ant project...")
430         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
431
432     if p is not None and p.returncode != 0:
433         raise BuildException("Error cleaning %s:%s" %
434                 (app['id'], thisbuild['version']), p.stdout)
435
436     if not options.skipscan:
437         # Scan before building...
438         logging.info("Scanning source for common problems...")
439         buildprobs = common.scan_source(build_dir, root_dir, thisbuild)
440         if len(buildprobs) > 0:
441             logging.warn('Scanner found %d problems:' % len(buildprobs))
442             for problem in buildprobs:
443                 logging.info('    %s' % problem)
444             if not force:
445                 raise BuildException("Can't build due to " +
446                     str(len(buildprobs)) + " scanned problems")
447
448     if not options.notarball:
449         # Build the source tarball right before we build the release...
450         logging.info("Creating source tarball...")
451         tarname = common.getsrcname(app,thisbuild)
452         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
453         def tarexc(f):
454             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
455         tarball.add(build_dir, tarname, exclude=tarexc)
456         tarball.close()
457
458     # Run a build command if one is required...
459     if 'build' in thisbuild:
460         cmd = common.replace_config_vars(thisbuild['build'])
461         # Substitute source library paths into commands...
462         for name, number, libpath in srclibpaths:
463             libpath = os.path.relpath(libpath, root_dir)
464             cmd = cmd.replace('$$' + name + '$$', libpath)
465         logging.info("Running 'build' commands in %s" % root_dir)
466
467         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
468
469         if p.returncode != 0:
470             raise BuildException("Error running build command for %s:%s" %
471                     (app['id'], thisbuild['version']), p.stdout)
472
473     # Build native stuff if required...
474     if thisbuild.get('buildjni') not in (None, ['no']):
475         logging.info("Building native libraries...")
476         jni_components = thisbuild.get('buildjni')
477         if jni_components == ['yes']:
478             jni_components = ['']
479         jobs = multiprocessing.cpu_count()
480         ndkbuild = os.path.join(config['ndk_path'], "ndk-build")
481         cmd = [ndkbuild, "-j"+str(jobs)]
482         for d in jni_components:
483             logging.info("Building native code in '%s'" % d)
484             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
485             if os.path.exists(manifest):
486                 # Read and write the whole AM.xml to fix newlines and avoid
487                 # the ndk r8c or later 'wordlist' errors. The outcome of this
488                 # under gnu/linux is the same as when using tools like
489                 # dos2unix, but the native python way is faster and will
490                 # work in non-unix systems.
491                 manifest_text = open(manifest, 'U').read()
492                 open(manifest, 'w').write(manifest_text)
493                 # In case the AM.xml read was big, free the memory
494                 del manifest_text
495             p = FDroidPopen(cmd, cwd=os.path.join(root_dir,d))
496             if p.returncode != 0:
497                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
498
499     p = None
500     # Build the release...
501     if thisbuild['type'] == 'maven':
502         logging.info("Building Maven project...")
503
504         if '@' in thisbuild['maven']:
505             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
506         else:
507             maven_dir = root_dir
508
509         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
510                 '-Dandroid.sign.debug=false', '-Dmaven.test.skip=true',
511                 '-Dandroid.release=true', 'package']
512         if 'target' in thisbuild:
513             target = thisbuild["target"].split('-')[1]
514             FDroidPopen(['sed', '-i',
515                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
516                     'pom.xml'], cwd=root_dir)
517             if '@' in thisbuild['maven']:
518                 FDroidPopen(['sed', '-i',
519                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
520                         'pom.xml'], cwd=maven_dir)
521
522         if 'mvnflags' in thisbuild:
523             mvncmd += thisbuild['mvnflags']
524
525         p = FDroidPopen(mvncmd, cwd=maven_dir)
526
527         bindir = os.path.join(root_dir, 'target')
528
529     elif thisbuild['type'] == 'kivy':
530         logging.info("Building Kivy project...")
531
532         spec = os.path.join(root_dir, 'buildozer.spec')
533         if not os.path.exists(spec):
534             raise BuildException("Expected to find buildozer-compatible spec at {0}"
535                     .format(spec))
536
537         defaults = {'orientation': 'landscape', 'icon': '',
538                 'permissions': '', 'android.api': "18"}
539         bconfig = ConfigParser(defaults, allow_no_value=True)
540         bconfig.read(spec)
541
542         distdir = 'python-for-android/dist/fdroid'
543         if os.path.exists(distdir):
544             shutil.rmtree(distdir)
545
546         modules = bconfig.get('app', 'requirements').split(',')
547
548         cmd = 'ANDROIDSDK=' + config['sdk_path']
549         cmd += ' ANDROIDNDK=' + config['ndk_path']
550         cmd += ' ANDROIDNDKVER=r9'
551         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
552         cmd += ' VIRTUALENV=virtualenv'
553         cmd += ' ./distribute.sh'
554         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
555         cmd += ' -d fdroid'
556         p = FDroidPopen(cmd, cwd='python-for-android', shell=True)
557         if p.returncode != 0:
558             raise BuildException("Distribute build failed")
559
560         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
561         if cid != app['id']:
562             raise BuildException("Package ID mismatch between metadata and spec")
563
564         orientation = bconfig.get('app', 'orientation', 'landscape')
565         if orientation == 'all':
566             orientation = 'sensor'
567
568         cmd = ['./build.py'
569                 '--dir', root_dir,
570                 '--name', bconfig.get('app', 'title'),
571                 '--package', app['id'],
572                 '--version', bconfig.get('app', 'version'),
573                 '--orientation', orientation,
574                 ]
575
576         perms = bconfig.get('app', 'permissions')
577         for perm in perms.split(','):
578             cmd.extend(['--permission', perm])
579
580         if config.get('app', 'fullscreen') == 0:
581             cmd.append('--window')
582
583         icon = bconfig.get('app', 'icon.filename')
584         if icon:
585             cmd.extend(['--icon', os.path.join(root_dir, icon)])
586
587         cmd.append('release')
588         p = FDroidPopen(cmd, cwd=distdir)
589
590     elif thisbuild['type'] == 'gradle':
591         logging.info("Building Gradle project...")
592         if '@' in thisbuild['gradle']:
593             flavours = thisbuild['gradle'].split('@')[0].split(',')
594             gradle_dir = thisbuild['gradle'].split('@')[1]
595             gradle_dir = os.path.join(root_dir, gradle_dir)
596         else:
597             flavours = thisbuild['gradle'].split(',')
598             gradle_dir = root_dir
599
600         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
601             flavours[0] = ''
602
603         commands = [config['gradle']]
604         if 'preassemble' in thisbuild:
605             commands += thisbuild['preassemble'].split()
606         commands += ['assemble'+''.join(flavours)+'Release']
607
608         p = FDroidPopen(commands, cwd=gradle_dir)
609
610     elif thisbuild['type'] == 'ant':
611         logging.info("Building Ant project...")
612         cmd = ['ant']
613         if 'antcommand' in thisbuild:
614             cmd += [thisbuild['antcommand']]
615         else:
616             cmd += ['release']
617         p = FDroidPopen(cmd, cwd=root_dir)
618
619         bindir = os.path.join(root_dir, 'bin')
620
621     if p is not None and p.returncode != 0:
622         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
623     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
624
625     if thisbuild['type'] == 'maven':
626         stdout_apk = '\n'.join([
627             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
628         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
629                 stdout_apk, re.S|re.M)
630         if not m:
631             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
632                     stdout_apk, re.S|re.M)
633         if not m:
634             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
635                     stdout_apk, re.S|re.M)
636         if not m:
637             raise BuildException('Failed to find output')
638         src = m.group(1)
639         src = os.path.join(bindir, src) + '.apk'
640     elif thisbuild['type'] == 'kivy':
641         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
642                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
643     elif thisbuild['type'] == 'gradle':
644         dd = build_dir
645         if 'subdir' in thisbuild:
646             dd = os.path.join(dd, thisbuild['subdir'])
647         if len(flavours) == 1 and flavours[0] == '':
648             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
649         else:
650             name = '-'.join([os.path.basename(dd), '-'.join(flavours), 'release', 'unsigned'])
651         src = os.path.join(dd, 'build', 'apk', name+'.apk')
652     elif thisbuild['type'] == 'ant':
653         stdout_apk = '\n'.join([
654             line for line in p.stdout.splitlines() if '.apk' in line])
655         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
656             re.S|re.M).group(1)
657         src = os.path.join(bindir, src)
658     elif thisbuild['type'] == 'raw':
659         src = os.path.join(root_dir, thisbuild['output'])
660         src = os.path.normpath(src)
661
662     # Make sure it's not debuggable...
663     if common.isApkDebuggable(src, config):
664         raise BuildException("APK is debuggable")
665
666     # By way of a sanity check, make sure the version and version
667     # code in our new apk match what we expect...
668     logging.info("Checking " + src)
669     if not os.path.exists(src):
670         raise BuildException("Unsigned apk is not at expected location of " + src)
671
672     p = SilentPopen([os.path.join(config['sdk_path'],
673         'build-tools', config['build_tools'], 'aapt'),
674         'dump', 'badging', src])
675
676     vercode = None
677     version = None
678     foundid = None
679     for line in p.stdout.splitlines():
680         if line.startswith("package:"):
681             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
682             m = pat.match(line)
683             if m:
684                 foundid = m.group(1)
685             pat = re.compile(".*versionCode='([0-9]*)'.*")
686             m = pat.match(line)
687             if m:
688                 vercode = m.group(1)
689             pat = re.compile(".*versionName='([^']*)'.*")
690             m = pat.match(line)
691             if m:
692                 version = m.group(1)
693
694     if thisbuild['novcheck']:
695         vercode = thisbuild['vercode']
696         version = thisbuild['version']
697     if not version or not vercode:
698         raise BuildException("Could not find version information in build in output")
699     if not foundid:
700         raise BuildException("Could not find package ID in output")
701     if foundid != app['id']:
702         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
703
704     # Some apps (e.g. Timeriffic) have had the bonkers idea of
705     # including the entire changelog in the version number. Remove
706     # it so we can compare. (TODO: might be better to remove it
707     # before we compile, in fact)
708     index = version.find(" //")
709     if index != -1:
710         version = version[:index]
711
712     if (version != thisbuild['version'] or
713             vercode != thisbuild['vercode']):
714         raise BuildException(("Unexpected version/version code in output;"
715                              " APK: '%s' / '%s', "
716                              " Expected: '%s' / '%s'")
717                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
718                             )
719
720     # Copy the unsigned apk to our destination directory for further
721     # processing (by publish.py)...
722     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
723     shutil.copyfile(src, dest)
724
725     # Move the source tarball into the output directory...
726     if output_dir != tmp_dir and not options.notarball:
727         shutil.move(os.path.join(tmp_dir, tarname),
728             os.path.join(output_dir, tarname))
729
730
731 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
732         tmp_dir, repo_dir, vcs, test, server, force, onserver):
733     """
734     Build a particular version of an application, if it needs building.
735
736     :param output_dir: The directory where the build output will go. Usually
737        this is the 'unsigned' directory.
738     :param repo_dir: The repo directory - used for checking if the build is
739        necessary.
740     :paaram also_check_dir: An additional location for checking if the build
741        is necessary (usually the archive repo)
742     :param test: True if building in test mode, in which case the build will
743        always happen, even if the output already exists. In test mode, the
744        output directory should be a temporary location, not any of the real
745        ones.
746
747     :returns: True if the build was done, False if it wasn't necessary.
748     """
749
750     dest_apk = common.getapkname(app, thisbuild)
751
752     dest = os.path.join(output_dir, dest_apk)
753     dest_repo = os.path.join(repo_dir, dest_apk)
754
755     if not test:
756         if os.path.exists(dest) or os.path.exists(dest_repo):
757             return False
758
759         if also_check_dir:
760             dest_also = os.path.join(also_check_dir, dest_apk)
761             if os.path.exists(dest_also):
762                 return False
763
764     if 'disable' in thisbuild:
765         return False
766
767     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
768
769     if server:
770         # When using server mode, still keep a local cache of the repo, by
771         # grabbing the source now.
772         vcs.gotorevision(thisbuild['commit'])
773
774         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
775     else:
776         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
777     return True
778
779
780 def parse_commandline():
781     """Parse the command line. Returns options, args."""
782
783     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
784     parser.add_option("-v", "--verbose", action="store_true", default=False,
785                       help="Spew out even more information than normal")
786     parser.add_option("-q", "--quiet", action="store_true", default=False,
787                       help="Restrict output to warnings and errors")
788     parser.add_option("-l", "--latest", action="store_true", default=False,
789                       help="Build only the latest version of each package")
790     parser.add_option("-s", "--stop", action="store_true", default=False,
791                       help="Make the build stop on exceptions")
792     parser.add_option("-t", "--test", action="store_true", default=False,
793                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
794     parser.add_option("--server", action="store_true", default=False,
795                       help="Use build server")
796     parser.add_option("--resetserver", action="store_true", default=False,
797                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
798     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
799                       help="Specify that we're running on the build server")
800     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
801                       help="Skip scanning the source code for binaries and other problems")
802     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
803                       help="Don't create a source tarball, useful when testing a build")
804     parser.add_option("-f", "--force", action="store_true", default=False,
805                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
806     parser.add_option("-a", "--all", action="store_true", default=False,
807                       help="Build all applications available")
808     parser.add_option("-w", "--wiki", default=False, action="store_true",
809                       help="Update the wiki")
810     options, args = parser.parse_args()
811
812     # Force --stop with --on-server to get cotrect exit code
813     if options.onserver:
814         options.stop = True
815
816     if options.force and not options.test:
817         raise OptionError("Force is only allowed in test mode", "force")
818
819     return options, args
820
821 options = None
822 config = None
823
824 def main():
825
826     global options, config
827
828     options, args = parse_commandline()
829     if not args and not options.all:
830         raise OptionError("If you really want to build all the apps, use --all", "all")
831
832     config = common.read_config(options)
833
834     if config['build_server_always']:
835         options.server = True
836     if options.resetserver and not options.server:
837         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
838
839     log_dir = 'logs'
840     if not os.path.isdir(log_dir):
841         logging.info("Creating log directory")
842         os.makedirs(log_dir)
843
844     tmp_dir = 'tmp'
845     if not os.path.isdir(tmp_dir):
846         logging.info("Creating temporary directory")
847         os.makedirs(tmp_dir)
848
849     if options.test:
850         output_dir = tmp_dir
851     else:
852         output_dir = 'unsigned'
853         if not os.path.isdir(output_dir):
854             logging.info("Creating output directory")
855             os.makedirs(output_dir)
856
857     if config['archive_older'] != 0:
858         also_check_dir = 'archive'
859     else:
860         also_check_dir = None
861
862     repo_dir = 'repo'
863
864     build_dir = 'build'
865     if not os.path.isdir(build_dir):
866         logging.info("Creating build directory")
867         os.makedirs(build_dir)
868     srclib_dir = os.path.join(build_dir, 'srclib')
869     extlib_dir = os.path.join(build_dir, 'extlib')
870
871     # Get all apps...
872     allapps = metadata.read_metadata(xref=not options.onserver)
873
874     apps = common.read_app_args(args, allapps, True)
875     apps = [app for app in apps if (options.force or not app['Disabled']) and
876             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
877
878     if len(apps) == 0:
879         raise Exception("No apps to process.")
880
881     if options.latest:
882         for app in apps:
883             for build in reversed(app['builds']):
884                 if 'disable' in build:
885                     continue
886                 app['builds'] = [ build ]
887                 break
888
889     if options.wiki:
890         import mwclient
891         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
892                 path=config['wiki_path'])
893         site.login(config['wiki_user'], config['wiki_password'])
894
895     # Build applications...
896     failed_apps = {}
897     build_succeeded = []
898     for app in apps:
899
900         first = True
901
902         for thisbuild in app['builds']:
903             wikilog = None
904             try:
905
906                 # For the first build of a particular app, we need to set up
907                 # the source repo. We can reuse it on subsequent builds, if
908                 # there are any.
909                 if first:
910                     if app['Repo Type'] == 'srclib':
911                         build_dir = os.path.join('build', 'srclib', app['Repo'])
912                     else:
913                         build_dir = os.path.join('build', app['id'])
914
915                     # Set up vcs interface and make sure we have the latest code...
916                     logging.debug("Getting {0} vcs interface for {1}".format(
917                             app['Repo Type'], app['Repo']))
918                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
919
920                     first = False
921
922                 logging.debug("Checking " + thisbuild['version'])
923                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
924                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
925                         options.server, options.force, options.onserver):
926                     build_succeeded.append(app)
927                     wikilog = "Build succeeded"
928             except BuildException as be:
929                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
930                 logfile.write(str(be))
931                 logfile.close()
932                 print("Could not build app %s due to BuildException: %s" % (app['id'], be))
933                 if options.stop:
934                     sys.exit(1)
935                 failed_apps[app['id']] = be
936                 wikilog = be.get_wikitext()
937             except VCSException as vcse:
938                 print("VCS error while building app %s: %s" % (app['id'], vcse))
939                 if options.stop:
940                     sys.exit(1)
941                 failed_apps[app['id']] = vcse
942                 wikilog = str(vcse)
943             except Exception as e:
944                 print("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
945                 if options.stop:
946                     sys.exit(1)
947                 failed_apps[app['id']] = e
948                 wikilog = str(e)
949
950             if options.wiki and wikilog:
951                 try:
952                     newpage = site.Pages[app['id'] + '/lastbuild']
953                     txt = wikilog
954                     if len(txt) > 8192:
955                         txt = txt[-8192:]
956                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
957                     newpage.save(txt, summary='Build log')
958                 except:
959                     logging.info("Error while attempting to publish build log")
960
961     for app in build_succeeded:
962         logging.info("success: %s" % (app['id']))
963
964     if not options.verbose:
965         for fa in failed_apps:
966             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
967
968     logging.info("Finished.")
969     if len(build_succeeded) > 0:
970         logging.info(str(len(build_succeeded)) + ' builds succeeded')
971     if len(failed_apps) > 0:
972         logging.info(str(len(failed_apps)) + ' builds failed')
973
974     sys.exit(0)
975
976 if __name__ == "__main__":
977     main()
978