chiark / gitweb /
New build option: --no-tarball
[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
33 import common, metadata
34 from common import BuildException, VCSException, FDroidPopen
35
36 def get_builder_vm_id():
37     vd = os.path.join('builder', '.vagrant')
38     if os.path.isdir(vd):
39         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
40         with open(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')) as vf:
41             id = vf.read()
42         return id
43     else:
44         # Vagrant 1.0 - it's a json file...
45         with open(os.path.join('builder', '.vagrant')) as vf:
46             v = json.load(vf)
47         return v['active']['default']
48
49 def got_valid_builder_vm():
50     """Returns True if we have a valid-looking builder vm
51     """
52     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
53         return False
54     vd = os.path.join('builder', '.vagrant')
55     if not os.path.exists(vd):
56         return False
57     if not os.path.isdir(vd):
58         # Vagrant 1.0 - if the directory is there, it's valid...
59         return True
60     # Vagrant 1.2 - the directory can exist, but the id can be missing...
61     if not os.path.exists(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')):
62         return False
63     return True
64
65
66 def vagrant(params, cwd=None, printout=False):
67     """Run vagrant.
68
69     :param: list of parameters to pass to vagrant
70     :cwd: directory to run in, or None for current directory
71     :returns: (ret, out) where ret is the return code, and out
72                is the stdout (and stderr) from vagrant
73     """
74     p = subprocess.Popen(['vagrant'] + params, cwd=cwd,
75             stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
76     out = p.communicate()[0]
77     if options.verbose:
78         print out
79     return (p.returncode, out)
80
81
82 # Note that 'force' here also implies test mode.
83 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
84     """Do a build on the build server."""
85
86     import ssh
87
88     # Reset existing builder machine to a clean state if possible.
89     vm_ok = False
90     if not options.resetserver:
91         print "Checking for valid existing build server"
92         if got_valid_builder_vm():
93             print "...VM is present"
94             p = subprocess.Popen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'],
95                 cwd='builder', stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
96             output = p.communicate()[0]
97             if 'fdroidclean' in output:
98                 if options.verbose:
99                     print "...snapshot exists - resetting build server to clean state"
100                 retcode, output = vagrant(['status'], cwd='builder')
101                 if 'running' in output:
102                     if options.verbose:
103                         print "...suspending"
104                     vagrant(['suspend'], cwd='builder')
105                     print "...waiting a sec..."
106                     time.sleep(10)
107                 p = subprocess.Popen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'restore', 'fdroidclean'],
108                     cwd='builder', stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
109                 output = p.communicate()[0]
110                 if options.verbose:
111                     print output
112                 if p.returncode == 0:
113                     print "...reset to snapshot - server is valid"
114                     retcode, output = vagrant(['up'], cwd='builder')
115                     if retcode != 0:
116                         raise BuildException("Failed to start build server")
117                     print "...waiting a sec..."
118                     time.sleep(10)
119                     vm_ok = True
120                 else:
121                     print "...failed to reset to snapshot"
122             else:
123                 print "...snapshot doesn't exist - VBoxManage snapshot list:\n" + output
124
125     # If we can't use the existing machine for any reason, make a
126     # new one from scratch.
127     if not vm_ok:
128         if os.path.exists('builder'):
129             print "Removing broken/incomplete/unwanted build server"
130             vagrant(['destroy', '-f'], cwd='builder')
131             shutil.rmtree('builder')
132         os.mkdir('builder')
133
134         p = subprocess.Popen('vagrant --version', shell=True, stdout=subprocess.PIPE)
135         vver = p.communicate()[0]
136         if vver.startswith('Vagrant version 1.2'):
137             with open('builder/Vagrantfile', 'w') as vf:
138                 vf.write('Vagrant.configure("2") do |config|\n')
139                 vf.write('config.vm.box = "buildserver"\n')
140                 vf.write('end\n')
141         else:
142             with open('builder/Vagrantfile', 'w') as vf:
143                 vf.write('Vagrant::Config.run do |config|\n')
144                 vf.write('config.vm.box = "buildserver"\n')
145                 vf.write('end\n')
146
147         print "Starting new build server"
148         retcode, _ = vagrant(['up'], cwd='builder')
149         if retcode != 0:
150             raise BuildException("Failed to start build server")
151
152         # Open SSH connection to make sure it's working and ready...
153         print "Connecting to virtual machine..."
154         if subprocess.call('vagrant ssh-config >sshconfig',
155                 cwd='builder', shell=True) != 0:
156             raise BuildException("Error getting ssh config")
157         vagranthost = 'default' # Host in ssh config file
158         sshconfig = ssh.SSHConfig()
159         sshf = open('builder/sshconfig', 'r')
160         sshconfig.parse(sshf)
161         sshf.close()
162         sshconfig = sshconfig.lookup(vagranthost)
163         sshs = ssh.SSHClient()
164         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
165         idfile = sshconfig['identityfile']
166         if idfile.startswith('"') and idfile.endswith('"'):
167             idfile = idfile[1:-1]
168         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
169             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
170             key_filename=idfile)
171         sshs.close()
172
173         print "Saving clean state of new build server"
174         retcode, _ = vagrant(['suspend'], cwd='builder')
175         if retcode != 0:
176             raise BuildException("Failed to suspend build server")
177         print "...waiting a sec..."
178         time.sleep(10)
179         p = subprocess.Popen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'take', 'fdroidclean'],
180                 cwd='builder', stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
181         output = p.communicate()[0]
182         if p.returncode != 0:
183             print output
184             raise BuildException("Failed to take snapshot")
185         print "...waiting a sec..."
186         time.sleep(10)
187         print "Restarting new build server"
188         retcode, _ = vagrant(['up'], cwd='builder')
189         if retcode != 0:
190             raise BuildException("Failed to start build server")
191         print "...waiting a sec..."
192         time.sleep(10)
193         # Make sure it worked...
194         p = subprocess.Popen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'],
195             cwd='builder', stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
196         output = p.communicate()[0]
197         if 'fdroidclean' not in output:
198             raise BuildException("Failed to take snapshot.")
199
200     try:
201
202         # Get SSH configuration settings for us to connect...
203         if options.verbose:
204             print "Getting ssh configuration..."
205         subprocess.call('vagrant ssh-config >sshconfig',
206                 cwd='builder', shell=True)
207         vagranthost = 'default' # Host in ssh config file
208
209         # Load and parse the SSH config...
210         sshconfig = ssh.SSHConfig()
211         sshf = open('builder/sshconfig', 'r')
212         sshconfig.parse(sshf)
213         sshf.close()
214         sshconfig = sshconfig.lookup(vagranthost)
215
216         # Open SSH connection...
217         if options.verbose:
218             print "Connecting to virtual machine..."
219         sshs = ssh.SSHClient()
220         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
221         idfile = sshconfig['identityfile']
222         if idfile.startswith('"') and idfile.endswith('"'):
223             idfile = idfile[1:-1]
224         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
225             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
226             key_filename=idfile)
227
228         # Get an SFTP connection...
229         ftp = sshs.open_sftp()
230         ftp.get_channel().settimeout(15)
231
232         # Put all the necessary files in place...
233         ftp.chdir('/home/vagrant')
234
235         # Helper to copy the contents of a directory to the server...
236         def send_dir(path):
237             root = os.path.dirname(path)
238             main = os.path.basename(path)
239             ftp.mkdir(main)
240             for r, d, f in os.walk(path):
241                 rr = os.path.relpath(r, root)
242                 ftp.chdir(rr)
243                 for dd in d:
244                     ftp.mkdir(dd)
245                 for ff in f:
246                     lfile = os.path.join(root, rr, ff)
247                     if not os.path.islink(lfile):
248                         ftp.put(lfile, ff)
249                         ftp.chmod(ff, os.stat(lfile).st_mode)
250                 for i in range(len(rr.split('/'))):
251                     ftp.chdir('..')
252             ftp.chdir('..')
253
254         print "Preparing server for build..."
255         serverpath = os.path.abspath(os.path.dirname(__file__))
256         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
257         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
258         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
259         ftp.put(os.path.join(serverpath, '..', 'config.buildserver.py'), 'config.py')
260         ftp.chmod('config.py', 0o600)
261
262         # Copy the metadata - just the file for this app...
263         ftp.mkdir('metadata')
264         ftp.mkdir('srclibs')
265         ftp.chdir('metadata')
266         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
267                 app['id'] + '.txt')
268         # And patches if there are any...
269         if os.path.exists(os.path.join('metadata', app['id'])):
270             send_dir(os.path.join('metadata', app['id']))
271
272         ftp.chdir('/home/vagrant')
273         # Create the build directory...
274         ftp.mkdir('build')
275         ftp.chdir('build')
276         ftp.mkdir('extlib')
277         ftp.mkdir('srclib')
278         # Copy any extlibs that are required...
279         if 'extlibs' in thisbuild:
280             ftp.chdir('/home/vagrant/build/extlib')
281             for lib in thisbuild['extlibs'].split(';'):
282                 lib = lib.strip()
283                 libsrc = os.path.join('build/extlib', lib)
284                 if not os.path.exists(libsrc):
285                     raise BuildException("Missing extlib {0}".format(libsrc))
286                 lp = lib.split('/')
287                 for d in lp[:-1]:
288                     if d not in ftp.listdir():
289                         ftp.mkdir(d)
290                     ftp.chdir(d)
291                 ftp.put(libsrc, lp[-1])
292                 for _ in lp[:-1]:
293                     ftp.chdir('..')
294         # Copy any srclibs that are required...
295         srclibpaths = []
296         if 'srclibs' in thisbuild:
297             for lib in thisbuild['srclibs'].split(';'):
298                 srclibpaths.append(common.getsrclib(lib, 'build/srclib', srclibpaths,
299                     basepath=True, prepare=False))
300
301         # If one was used for the main source, add that too.
302         basesrclib = vcs.getsrclib()
303         if basesrclib:
304             srclibpaths.append(basesrclib)
305         for name, number, lib in srclibpaths:
306             if options.verbose:
307                 print "Sending srclib '" + lib + "'"
308             ftp.chdir('/home/vagrant/build/srclib')
309             if not os.path.exists(lib):
310                 raise BuildException("Missing srclib directory '" + lib + "'")
311             fv = '.fdroidvcs-' + name
312             ftp.put(os.path.join('build/srclib', fv), fv)
313             send_dir(lib)
314             # Copy the metadata file too...
315             ftp.chdir('/home/vagrant/srclibs')
316             ftp.put(os.path.join('srclibs', name + '.txt'),
317                     name + '.txt')
318         # Copy the main app source code
319         # (no need if it's a srclib)
320         if (not basesrclib) and os.path.exists(build_dir):
321             ftp.chdir('/home/vagrant/build')
322             fv = '.fdroidvcs-' + app['id']
323             ftp.put(os.path.join('build', fv), fv)
324             send_dir(build_dir)
325
326         # Execute the build script...
327         print "Starting build..."
328         chan = sshs.get_transport().open_session()
329         chan.get_pty()
330         cmdline = 'python build.py --on-server'
331         if force:
332             cmdline += ' --force --test'
333         if options.verbose:
334             cmdline += ' --verbose'
335         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
336         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
337         output = ''
338         while not chan.exit_status_ready():
339             while chan.recv_ready():
340                 output += chan.recv(1024)
341             time.sleep(0.1)
342         print "...getting exit status"
343         returncode = chan.recv_exit_status()
344         while True:
345             get = chan.recv(1024)
346             if len(get) == 0:
347                 break
348             output += get
349         if returncode != 0:
350             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output)
351
352         # Retrieve the built files...
353         print "Retrieving build output..."
354         if force:
355             ftp.chdir('/home/vagrant/tmp')
356         else:
357             ftp.chdir('/home/vagrant/unsigned')
358         apkfile = common.getapkname(app,thisbuild)
359         tarball = common.getsrcname(app,thisbuild)
360         try:
361             ftp.get(apkfile, os.path.join(output_dir, apkfile))
362             if not options.notarball:
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     if not options.notarball:
448         # Build the source tarball right before we build the release...
449         print "Creating source tarball..."
450         tarname = common.getsrcname(app,thisbuild)
451         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
452         def tarexc(f):
453             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
454         tarball.add(build_dir, tarname, exclude=tarexc)
455         tarball.close()
456
457     # Run a build command if one is required...
458     if 'build' in thisbuild:
459         cmd = common.replace_config_vars(thisbuild['build'])
460         # Substitute source library paths into commands...
461         for name, number, libpath in srclibpaths:
462             libpath = os.path.relpath(libpath, root_dir)
463             cmd = cmd.replace('$$' + name + '$$', libpath)
464         if options.verbose:
465             print "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         print "Building native libraries..."
476         jni_components = thisbuild.get('buildjni')
477         if jni_components == 'yes':
478             jni_components = ['']
479         else:
480             jni_components = [c.strip() for c in jni_components.split(';')]
481         ndkbuild = os.path.join(config['ndk_path'], "ndk-build")
482         for d in jni_components:
483             if options.verbose:
484                 print "Running ndk-build in " + root_dir + '/' + d
485             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
486             if os.path.exists(manifest):
487                 # Read and write the whole AM.xml to fix newlines and avoid
488                 # the ndk r8c or later 'wordlist' errors. The outcome of this
489                 # under gnu/linux is the same as when using tools like
490                 # dos2unix, but the native python way is faster and will
491                 # work in non-unix systems.
492                 manifest_text = open(manifest, 'U').read()
493                 open(manifest, 'w').write(manifest_text)
494                 # In case the AM.xml read was big, free the memory
495                 del manifest_text
496             p = FDroidPopen([ndkbuild], cwd=os.path.join(root_dir,d))
497             if p.returncode != 0:
498                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
499
500     p = None
501     # Build the release...
502     if thisbuild['type'] == 'maven':
503         print "Building Maven project..."
504
505         if '@' in thisbuild['maven']:
506             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
507         else:
508             maven_dir = root_dir
509
510         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
511                 '-Dandroid.sign.debug=false', '-Dmaven.test.skip=true',
512                 '-Dandroid.release=true', 'package']
513         if 'target' in thisbuild:
514             target = thisbuild["target"].split('-')[1]
515             subprocess.call(['sed', '-i',
516                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
517                     'pom.xml'], cwd=root_dir)
518             if '@' in thisbuild['maven']:
519                 subprocess.call(['sed', '-i',
520                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
521                         'pom.xml'], cwd=maven_dir)
522
523         if 'mvnflags' in thisbuild:
524             mvncmd += thisbuild['mvnflags']
525
526         p = FDroidPopen(mvncmd, cwd=maven_dir)
527
528         bindir = os.path.join(root_dir, 'target')
529
530     elif thisbuild['type'] == 'kivy':
531         print "Building Kivy project..."
532
533         spec = os.path.join(root_dir, 'buildozer.spec')
534         if not os.path.exists(spec):
535             raise BuildException("Expected to find buildozer-compatible spec at {0}"
536                     .format(spec))
537
538         defaults = {'orientation': 'landscape', 'icon': '',
539                 'permissions': '', 'android.api': "18"}
540         bconfig = ConfigParser(defaults, allow_no_value=True)
541         bconfig.read(spec)
542
543         distdir = 'python-for-android/dist/fdroid'
544         if os.path.exists(distdir):
545             shutil.rmtree(distdir)
546
547         modules = bconfig.get('app', 'requirements').split(',')
548
549         cmd = 'ANDROIDSDK=' + config['sdk_path']
550         cmd += ' ANDROIDNDK=' + config['ndk_path']
551         cmd += ' ANDROIDNDKVER=r9'
552         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
553         cmd += ' VIRTUALENV=virtualenv'
554         cmd += ' ./distribute.sh'
555         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
556         cmd += ' -d fdroid'
557         if subprocess.call(cmd, cwd='python-for-android', shell=True) != 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         print "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     else:
611         print "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.returncode != 0:
622         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
623     print "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     else:
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
659     # Make sure it's not debuggable...
660     if common.isApkDebuggable(src, config):
661         raise BuildException("APK is debuggable")
662
663     # By way of a sanity check, make sure the version and version
664     # code in our new apk match what we expect...
665     print "Checking " + src
666     if not os.path.exists(src):
667         raise BuildException("Unsigned apk is not at expected location of " + src)
668
669     p = subprocess.Popen([os.path.join(config['sdk_path'],
670                         'build-tools', config['build_tools'], 'aapt'),
671                         'dump', 'badging', src],
672                         stdout=subprocess.PIPE)
673     output = p.communicate()[0]
674
675     vercode = None
676     version = None
677     foundid = None
678     for line in output.splitlines():
679         if line.startswith("package:"):
680             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
681             m = pat.match(line)
682             if m:
683                 foundid = m.group(1)
684             pat = re.compile(".*versionCode='([0-9]*)'.*")
685             m = pat.match(line)
686             if m:
687                 vercode = m.group(1)
688             pat = re.compile(".*versionName='([^']*)'.*")
689             m = pat.match(line)
690             if m:
691                 version = m.group(1)
692
693     if thisbuild['novcheck']:
694         vercode = thisbuild['vercode']
695         version = thisbuild['version']
696     if not version or not vercode:
697         raise BuildException("Could not find version information in build in output")
698     if not foundid:
699         raise BuildException("Could not find package ID in output")
700     if foundid != app['id']:
701         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
702
703     # Some apps (e.g. Timeriffic) have had the bonkers idea of
704     # including the entire changelog in the version number. Remove
705     # it so we can compare. (TODO: might be better to remove it
706     # before we compile, in fact)
707     index = version.find(" //")
708     if index != -1:
709         version = version[:index]
710
711     if (version != thisbuild['version'] or
712             vercode != thisbuild['vercode']):
713         raise BuildException(("Unexpected version/version code in output;"
714                              " APK: '%s' / '%s', "
715                              " Expected: '%s' / '%s'")
716                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
717                             )
718
719     # Copy the unsigned apk to our destination directory for further
720     # processing (by publish.py)...
721     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
722     shutil.copyfile(src, dest)
723
724     # Move the source tarball into the output directory...
725     if output_dir != tmp_dir and not options.notarball:
726         shutil.move(os.path.join(tmp_dir, tarname),
727             os.path.join(output_dir, tarname))
728
729
730 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
731         tmp_dir, repo_dir, vcs, test, server, force, onserver):
732     """
733     Build a particular version of an application, if it needs building.
734
735     :param output_dir: The directory where the build output will go. Usually
736        this is the 'unsigned' directory.
737     :param repo_dir: The repo directory - used for checking if the build is
738        necessary.
739     :paaram also_check_dir: An additional location for checking if the build
740        is necessary (usually the archive repo)
741     :param test: True if building in test mode, in which case the build will
742        always happen, even if the output already exists. In test mode, the
743        output directory should be a temporary location, not any of the real
744        ones.
745
746     :returns: True if the build was done, False if it wasn't necessary.
747     """
748
749     dest_apk = common.getapkname(app, thisbuild)
750
751     dest = os.path.join(output_dir, dest_apk)
752     dest_repo = os.path.join(repo_dir, dest_apk)
753
754     if not test:
755         if os.path.exists(dest) or os.path.exists(dest_repo):
756             return False
757
758         if also_check_dir:
759             dest_also = os.path.join(also_check_dir, dest_apk)
760             if os.path.exists(dest_also):
761                 return False
762
763     if 'disable' in thisbuild:
764         return False
765
766     print "Building version " + thisbuild['version'] + ' of ' + app['id']
767
768     if server:
769         # When using server mode, still keep a local cache of the repo, by
770         # grabbing the source now.
771         vcs.gotorevision(thisbuild['commit'])
772
773         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
774     else:
775         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
776     return True
777
778
779 def parse_commandline():
780     """Parse the command line. Returns options, args."""
781
782     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
783     parser.add_option("-v", "--verbose", action="store_true", default=False,
784                       help="Spew out even more information than normal")
785     parser.add_option("-l", "--latest", action="store_true", default=False,
786                       help="Build only the latest version of each package")
787     parser.add_option("-s", "--stop", action="store_true", default=False,
788                       help="Make the build stop on exceptions")
789     parser.add_option("-t", "--test", action="store_true", default=False,
790                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
791     parser.add_option("--server", action="store_true", default=False,
792                       help="Use build server")
793     parser.add_option("--resetserver", action="store_true", default=False,
794                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
795     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
796                       help="Specify that we're running on the build server")
797     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
798                       help="Don't create a source tarball, useful when testing a build")
799     parser.add_option("-f", "--force", action="store_true", default=False,
800                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
801     parser.add_option("-a", "--all", action="store_true", default=False,
802                       help="Build all applications available")
803     parser.add_option("-w", "--wiki", default=False, action="store_true",
804                       help="Update the wiki")
805     options, args = parser.parse_args()
806
807     # Force --stop with --on-server to get cotrect exit code
808     if options.onserver:
809         options.stop = True
810
811     if options.force and not options.test:
812         raise OptionError("Force is only allowed in test mode", "force")
813
814     return options, args
815
816 options = None
817 config = None
818
819 def main():
820
821     global options, config
822
823     options, args = parse_commandline()
824     if not args and not options.all:
825         raise OptionError("If you really want to build all the apps, use --all", "all")
826
827     config = common.read_config(options)
828
829     if config['build_server_always']:
830         options.server = True
831     if options.resetserver and not options.server:
832         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
833
834     log_dir = 'logs'
835     if not os.path.isdir(log_dir):
836         print "Creating log directory"
837         os.makedirs(log_dir)
838
839     tmp_dir = 'tmp'
840     if not os.path.isdir(tmp_dir):
841         print "Creating temporary directory"
842         os.makedirs(tmp_dir)
843
844     if options.test:
845         output_dir = tmp_dir
846     else:
847         output_dir = 'unsigned'
848         if not os.path.isdir(output_dir):
849             print "Creating output directory"
850             os.makedirs(output_dir)
851
852     if config['archive_older'] != 0:
853         also_check_dir = 'archive'
854     else:
855         also_check_dir = None
856
857     repo_dir = 'repo'
858
859     build_dir = 'build'
860     if not os.path.isdir(build_dir):
861         print "Creating build directory"
862         os.makedirs(build_dir)
863     srclib_dir = os.path.join(build_dir, 'srclib')
864     extlib_dir = os.path.join(build_dir, 'extlib')
865
866     # Get all apps...
867     allapps = metadata.read_metadata(xref=not options.onserver)
868
869     apps = common.read_app_args(args, allapps, True)
870     apps = [app for app in apps if (options.force or not app['Disabled']) and
871             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
872
873     if len(apps) == 0:
874         raise Exception("No apps to process.")
875
876     if options.latest:
877         for app in apps:
878             for build in reversed(app['builds']):
879                 if 'disable' in build:
880                     continue
881                 app['builds'] = [ build ]
882                 break
883
884     if options.wiki:
885         import mwclient
886         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
887                 path=config['wiki_path'])
888         site.login(config['wiki_user'], config['wiki_password'])
889
890     # Build applications...
891     failed_apps = {}
892     build_succeeded = []
893     for app in apps:
894
895         first = True
896
897         for thisbuild in app['builds']:
898             wikilog = None
899             try:
900
901                 # For the first build of a particular app, we need to set up
902                 # the source repo. We can reuse it on subsequent builds, if
903                 # there are any.
904                 if first:
905                     if app['Repo Type'] == 'srclib':
906                         build_dir = os.path.join('build', 'srclib', app['Repo'])
907                     else:
908                         build_dir = os.path.join('build', app['id'])
909
910                     # Set up vcs interface and make sure we have the latest code...
911                     if options.verbose:
912                         print "Getting {0} vcs interface for {1}".format(
913                                 app['Repo Type'], app['Repo'])
914                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
915
916                     first = False
917
918                 if options.verbose:
919                     print "Checking " + thisbuild['version']
920                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
921                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
922                         options.server, options.force, options.onserver):
923                     build_succeeded.append(app)
924                     wikilog = "Build succeeded"
925             except BuildException as be:
926                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
927                 logfile.write(str(be))
928                 logfile.close()
929                 print "Could not build app %s due to BuildException: %s" % (app['id'], be)
930                 if options.stop:
931                     sys.exit(1)
932                 failed_apps[app['id']] = be
933                 wikilog = be.get_wikitext()
934             except VCSException as vcse:
935                 print "VCS error while building app %s: %s" % (app['id'], vcse)
936                 if options.stop:
937                     sys.exit(1)
938                 failed_apps[app['id']] = vcse
939                 wikilog = str(vcse)
940             except Exception as e:
941                 print "Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
942                 if options.stop:
943                     sys.exit(1)
944                 failed_apps[app['id']] = e
945                 wikilog = str(e)
946
947             if options.wiki and wikilog:
948                 try:
949                     newpage = site.Pages[app['id'] + '/lastbuild']
950                     txt = wikilog
951                     if len(txt) > 8192:
952                         txt = txt[-8192:]
953                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
954                     newpage.save(txt, summary='Build log')
955                 except:
956                     print "Error while attempting to publish build log"
957
958     for app in build_succeeded:
959         print "success: %s" % (app['id'])
960
961     if not options.verbose:
962         for fa in failed_apps:
963             print "Build for app %s failed:\n%s" % (fa, failed_apps[fa])
964
965     print "Finished."
966     if len(build_succeeded) > 0:
967         print str(len(build_succeeded)) + ' builds succeeded'
968     if len(failed_apps) > 0:
969         print str(len(failed_apps)) + ' builds failed'
970
971     sys.exit(0)
972
973 if __name__ == "__main__":
974     main()
975