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