chiark / gitweb /
Lots more FDroidPopen replacements
[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
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 = FDroidPopen(['vagrant'] + params, cwd=cwd)
75     return (p.returncode, p.stdout)
76
77
78 # Note that 'force' here also implies test mode.
79 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
80     """Do a build on the build server."""
81
82     import ssh
83
84     # Reset existing builder machine to a clean state if possible.
85     vm_ok = False
86     if not options.resetserver:
87         print "Checking for valid existing build server"
88         if got_valid_builder_vm():
89             print "...VM is present"
90             p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'], cwd='builder')
91             if 'fdroidclean' in p.stdout:
92                 if options.verbose:
93                     print "...snapshot exists - resetting build server to clean state"
94                 retcode, output = vagrant(['status'], cwd='builder')
95                 if 'running' in output:
96                     if options.verbose:
97                         print "...suspending"
98                     vagrant(['suspend'], cwd='builder')
99                     print "...waiting a sec..."
100                     time.sleep(10)
101                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'restore', 'fdroidclean'],
102                     cwd='builder')
103                 if options.verbose:
104                     print output
105                 if p.returncode == 0:
106                     print "...reset to snapshot - server is valid"
107                     retcode, output = vagrant(['up'], cwd='builder')
108                     if retcode != 0:
109                         raise BuildException("Failed to start build server")
110                     print "...waiting a sec..."
111                     time.sleep(10)
112                     vm_ok = True
113                 else:
114                     print "...failed to reset to snapshot"
115             else:
116                 print "...snapshot doesn't exist - VBoxManage snapshot list:\n" + output
117
118     # If we can't use the existing machine for any reason, make a
119     # new one from scratch.
120     if not vm_ok:
121         if os.path.exists('builder'):
122             print "Removing broken/incomplete/unwanted build server"
123             vagrant(['destroy', '-f'], cwd='builder')
124             shutil.rmtree('builder')
125         os.mkdir('builder')
126
127         p = FDroidPopen('vagrant --version', shell=True, stdout=subprocess.PIPE)
128         if p.stdout.startswith('Vagrant version 1.2'):
129             with open('builder/Vagrantfile', 'w') as vf:
130                 vf.write('Vagrant.configure("2") do |config|\n')
131                 vf.write('config.vm.box = "buildserver"\n')
132                 vf.write('end\n')
133         else:
134             with open('builder/Vagrantfile', 'w') as vf:
135                 vf.write('Vagrant::Config.run do |config|\n')
136                 vf.write('config.vm.box = "buildserver"\n')
137                 vf.write('end\n')
138
139         print "Starting new build server"
140         retcode, _ = vagrant(['up'], cwd='builder')
141         if retcode != 0:
142             raise BuildException("Failed to start build server")
143
144         # Open SSH connection to make sure it's working and ready...
145         print "Connecting to virtual machine..."
146         if subprocess.call('vagrant ssh-config >sshconfig',
147                 cwd='builder', shell=True) != 0:
148             raise BuildException("Error getting ssh config")
149         vagranthost = 'default' # Host in ssh config file
150         sshconfig = ssh.SSHConfig()
151         sshf = open('builder/sshconfig', 'r')
152         sshconfig.parse(sshf)
153         sshf.close()
154         sshconfig = sshconfig.lookup(vagranthost)
155         sshs = ssh.SSHClient()
156         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
157         idfile = sshconfig['identityfile']
158         if idfile.startswith('"') and idfile.endswith('"'):
159             idfile = idfile[1:-1]
160         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
161             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
162             key_filename=idfile)
163         sshs.close()
164
165         print "Saving clean state of new build server"
166         retcode, _ = vagrant(['suspend'], cwd='builder')
167         if retcode != 0:
168             raise BuildException("Failed to suspend build server")
169         print "...waiting a sec..."
170         time.sleep(10)
171         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'take', 'fdroidclean'],
172                 cwd='builder')
173         if p.returncode != 0:
174             print p.stdout
175             raise BuildException("Failed to take snapshot")
176         print "...waiting a sec..."
177         time.sleep(10)
178         print "Restarting new build server"
179         retcode, _ = vagrant(['up'], cwd='builder')
180         if retcode != 0:
181             raise BuildException("Failed to start build server")
182         print "...waiting a sec..."
183         time.sleep(10)
184         # Make sure it worked...
185         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'],
186             cwd='builder')
187         if 'fdroidclean' not in p.stdout:
188             raise BuildException("Failed to take snapshot.")
189
190     try:
191
192         # Get SSH configuration settings for us to connect...
193         if options.verbose:
194             print "Getting ssh configuration..."
195         subprocess.call('vagrant ssh-config >sshconfig',
196                 cwd='builder', shell=True)
197         vagranthost = 'default' # Host in ssh config file
198
199         # Load and parse the SSH config...
200         sshconfig = ssh.SSHConfig()
201         sshf = open('builder/sshconfig', 'r')
202         sshconfig.parse(sshf)
203         sshf.close()
204         sshconfig = sshconfig.lookup(vagranthost)
205
206         # Open SSH connection...
207         if options.verbose:
208             print "Connecting to virtual machine..."
209         sshs = ssh.SSHClient()
210         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
211         idfile = sshconfig['identityfile']
212         if idfile.startswith('"') and idfile.endswith('"'):
213             idfile = idfile[1:-1]
214         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
215             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
216             key_filename=idfile)
217
218         # Get an SFTP connection...
219         ftp = sshs.open_sftp()
220         ftp.get_channel().settimeout(15)
221
222         # Put all the necessary files in place...
223         ftp.chdir('/home/vagrant')
224
225         # Helper to copy the contents of a directory to the server...
226         def send_dir(path):
227             root = os.path.dirname(path)
228             main = os.path.basename(path)
229             ftp.mkdir(main)
230             for r, d, f in os.walk(path):
231                 rr = os.path.relpath(r, root)
232                 ftp.chdir(rr)
233                 for dd in d:
234                     ftp.mkdir(dd)
235                 for ff in f:
236                     lfile = os.path.join(root, rr, ff)
237                     if not os.path.islink(lfile):
238                         ftp.put(lfile, ff)
239                         ftp.chmod(ff, os.stat(lfile).st_mode)
240                 for i in range(len(rr.split('/'))):
241                     ftp.chdir('..')
242             ftp.chdir('..')
243
244         print "Preparing server for build..."
245         serverpath = os.path.abspath(os.path.dirname(__file__))
246         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
247         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
248         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
249         ftp.put(os.path.join(serverpath, '..', 'config.buildserver.py'), 'config.py')
250         ftp.chmod('config.py', 0o600)
251
252         # Copy the metadata - just the file for this app...
253         ftp.mkdir('metadata')
254         ftp.mkdir('srclibs')
255         ftp.chdir('metadata')
256         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
257                 app['id'] + '.txt')
258         # And patches if there are any...
259         if os.path.exists(os.path.join('metadata', app['id'])):
260             send_dir(os.path.join('metadata', app['id']))
261
262         ftp.chdir('/home/vagrant')
263         # Create the build directory...
264         ftp.mkdir('build')
265         ftp.chdir('build')
266         ftp.mkdir('extlib')
267         ftp.mkdir('srclib')
268         # Copy any extlibs that are required...
269         if 'extlibs' in thisbuild:
270             ftp.chdir('/home/vagrant/build/extlib')
271             for lib in thisbuild['extlibs'].split(';'):
272                 lib = lib.strip()
273                 libsrc = os.path.join('build/extlib', lib)
274                 if not os.path.exists(libsrc):
275                     raise BuildException("Missing extlib {0}".format(libsrc))
276                 lp = lib.split('/')
277                 for d in lp[:-1]:
278                     if d not in ftp.listdir():
279                         ftp.mkdir(d)
280                     ftp.chdir(d)
281                 ftp.put(libsrc, lp[-1])
282                 for _ in lp[:-1]:
283                     ftp.chdir('..')
284         # Copy any srclibs that are required...
285         srclibpaths = []
286         if 'srclibs' in thisbuild:
287             for lib in thisbuild['srclibs'].split(';'):
288                 srclibpaths.append(common.getsrclib(lib, 'build/srclib', srclibpaths,
289                     basepath=True, prepare=False))
290
291         # If one was used for the main source, add that too.
292         basesrclib = vcs.getsrclib()
293         if basesrclib:
294             srclibpaths.append(basesrclib)
295         for name, number, lib in srclibpaths:
296             if options.verbose:
297                 print "Sending srclib '" + lib + "'"
298             ftp.chdir('/home/vagrant/build/srclib')
299             if not os.path.exists(lib):
300                 raise BuildException("Missing srclib directory '" + lib + "'")
301             fv = '.fdroidvcs-' + name
302             ftp.put(os.path.join('build/srclib', fv), fv)
303             send_dir(lib)
304             # Copy the metadata file too...
305             ftp.chdir('/home/vagrant/srclibs')
306             ftp.put(os.path.join('srclibs', name + '.txt'),
307                     name + '.txt')
308         # Copy the main app source code
309         # (no need if it's a srclib)
310         if (not basesrclib) and os.path.exists(build_dir):
311             ftp.chdir('/home/vagrant/build')
312             fv = '.fdroidvcs-' + app['id']
313             ftp.put(os.path.join('build', fv), fv)
314             send_dir(build_dir)
315
316         # Execute the build script...
317         print "Starting build..."
318         chan = sshs.get_transport().open_session()
319         chan.get_pty()
320         cmdline = 'python build.py --on-server'
321         if force:
322             cmdline += ' --force --test'
323         if options.verbose:
324             cmdline += ' --verbose'
325         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
326         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
327         output = ''
328         while not chan.exit_status_ready():
329             while chan.recv_ready():
330                 output += chan.recv(1024)
331             time.sleep(0.1)
332         print "...getting exit status"
333         returncode = chan.recv_exit_status()
334         while True:
335             get = chan.recv(1024)
336             if len(get) == 0:
337                 break
338             output += get
339         if returncode != 0:
340             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output)
341
342         # Retrieve the built files...
343         print "Retrieving build output..."
344         if force:
345             ftp.chdir('/home/vagrant/tmp')
346         else:
347             ftp.chdir('/home/vagrant/unsigned')
348         apkfile = common.getapkname(app,thisbuild)
349         tarball = common.getsrcname(app,thisbuild)
350         try:
351             ftp.get(apkfile, os.path.join(output_dir, apkfile))
352             ftp.get(tarball, os.path.join(output_dir, tarball))
353         except:
354             raise BuildException("Build failed for %s:%s - missing output files" % (app['id'], thisbuild['version']), output)
355         ftp.close()
356
357     finally:
358
359         # Suspend the build server.
360         print "Suspending build server"
361         subprocess.call(['vagrant', 'suspend'], cwd='builder')
362
363 def adapt_gradle(build_dir):
364     for root, dirs, files in os.walk(build_dir):
365         if 'build.gradle' in files:
366             path = os.path.join(root, 'build.gradle')
367             if options.verbose:
368                 print "Adapting build.gradle at %s" % path
369
370             subprocess.call(['sed', '-i',
371                     r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
372                     + config['build_tools'] + '"@g', path])
373
374
375 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
376     """Do a build locally."""
377
378     # Prepare the source code...
379     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
380             build_dir, srclib_dir, extlib_dir, onserver)
381
382     # We need to clean via the build tool in case the binary dirs are
383     # different from the default ones
384     p = None
385     if thisbuild['type'] == 'maven':
386         print "Cleaning Maven project..."
387         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
388
389         if '@' in thisbuild['maven']:
390             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
391             maven_dir = os.path.normpath(maven_dir)
392         else:
393             maven_dir = root_dir
394
395         p = FDroidPopen(cmd, cwd=maven_dir)
396
397     elif thisbuild['type'] == 'gradle':
398
399         print "Cleaning Gradle project..."
400         cmd = [config['gradle'], 'clean']
401
402         if '@' in thisbuild['gradle']:
403             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@',1)[1])
404             gradle_dir = os.path.normpath(gradle_dir)
405         else:
406             gradle_dir = root_dir
407
408         adapt_gradle(build_dir)
409         for name, number, libpath in srclibpaths:
410             adapt_gradle(libpath)
411
412         p = FDroidPopen(cmd, cwd=gradle_dir)
413
414     elif thisbuild['type'] == 'kivy':
415         pass
416
417     elif thisbuild['type'] == 'ant':
418         print "Cleaning Ant project..."
419         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
420
421     if p is not None and p.returncode != 0:
422         raise BuildException("Error cleaning %s:%s" %
423                 (app['id'], thisbuild['version']), p.stdout)
424
425     # Scan before building...
426     print "Scanning source for common problems..."
427     buildprobs = common.scan_source(build_dir, root_dir, thisbuild)
428     if len(buildprobs) > 0:
429         print 'Scanner found ' + str(len(buildprobs)) + ' problems:'
430         for problem in buildprobs:
431             print '    %s' % problem
432         if not force:
433             raise BuildException("Can't build due to " +
434                 str(len(buildprobs)) + " scanned problems")
435
436     # Build the source tarball right before we build the release...
437     print "Creating source tarball..."
438     tarname = common.getsrcname(app,thisbuild)
439     tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
440     def tarexc(f):
441         return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
442     tarball.add(build_dir, tarname, exclude=tarexc)
443     tarball.close()
444
445     # Run a build command if one is required...
446     if 'build' in thisbuild:
447         cmd = common.replace_config_vars(thisbuild['build'])
448         # Substitute source library paths into commands...
449         for name, number, libpath in srclibpaths:
450             libpath = os.path.relpath(libpath, root_dir)
451             cmd = cmd.replace('$$' + name + '$$', libpath)
452         if options.verbose:
453             print "Running 'build' commands in %s" % root_dir
454
455         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
456
457         if p.returncode != 0:
458             raise BuildException("Error running build command for %s:%s" %
459                     (app['id'], thisbuild['version']), p.stdout)
460
461     # Build native stuff if required...
462     if thisbuild.get('buildjni') not in (None, 'no'):
463         print "Building native libraries..."
464         jni_components = thisbuild.get('buildjni')
465         if jni_components == 'yes':
466             jni_components = ['']
467         else:
468             jni_components = [c.strip() for c in jni_components.split(';')]
469         ndkbuild = os.path.join(config['ndk_path'], "ndk-build")
470         for d in jni_components:
471             if options.verbose:
472                 print "Running ndk-build in " + root_dir + '/' + d
473             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
474             if os.path.exists(manifest):
475                 # Read and write the whole AM.xml to fix newlines and avoid
476                 # the ndk r8c or later 'wordlist' errors. The outcome of this
477                 # under gnu/linux is the same as when using tools like
478                 # dos2unix, but the native python way is faster and will
479                 # work in non-unix systems.
480                 manifest_text = open(manifest, 'U').read()
481                 open(manifest, 'w').write(manifest_text)
482                 # In case the AM.xml read was big, free the memory
483                 del manifest_text
484             p = FDroidPopen([ndkbuild], cwd=os.path.join(root_dir,d))
485             if p.returncode != 0:
486                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
487
488     p = None
489     # Build the release...
490     if thisbuild['type'] == 'maven':
491         print "Building Maven project..."
492
493         if '@' in thisbuild['maven']:
494             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
495         else:
496             maven_dir = root_dir
497
498         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
499                 '-Dandroid.sign.debug=false', '-Dmaven.test.skip=true',
500                 '-Dandroid.release=true', 'package']
501         if 'target' in thisbuild:
502             target = thisbuild["target"].split('-')[1]
503             subprocess.call(['sed', '-i',
504                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
505                     'pom.xml'], cwd=root_dir)
506             if '@' in thisbuild['maven']:
507                 subprocess.call(['sed', '-i',
508                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
509                         'pom.xml'], cwd=maven_dir)
510
511         if 'mvnflags' in thisbuild:
512             mvncmd += thisbuild['mvnflags']
513
514         p = FDroidPopen(mvncmd, cwd=maven_dir)
515
516         bindir = os.path.join(root_dir, 'target')
517
518     elif thisbuild['type'] == 'kivy':
519         print "Building Kivy project..."
520
521         spec = os.path.join(root_dir, 'buildozer.spec')
522         if not os.path.exists(spec):
523             raise BuildException("Expected to find buildozer-compatible spec at {0}"
524                     .format(spec))
525
526         defaults = {'orientation': 'landscape', 'icon': '',
527                 'permissions': '', 'android.api': "18"}
528         bconfig = ConfigParser(defaults, allow_no_value=True)
529         bconfig.read(spec)
530
531         distdir = 'python-for-android/dist/fdroid'
532         if os.path.exists(distdir):
533             shutil.rmtree(distdir)
534
535         modules = bconfig.get('app', 'requirements').split(',')
536
537         cmd = 'ANDROIDSDK=' + config['sdk_path']
538         cmd += ' ANDROIDNDK=' + config['ndk_path']
539         cmd += ' ANDROIDNDKVER=r9'
540         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
541         cmd += ' VIRTUALENV=virtualenv'
542         cmd += ' ./distribute.sh'
543         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
544         cmd += ' -d fdroid'
545         if subprocess.call(cmd, cwd='python-for-android', shell=True) != 0:
546             raise BuildException("Distribute build failed")
547
548         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
549         if cid != app['id']:
550             raise BuildException("Package ID mismatch between metadata and spec")
551
552         orientation = bconfig.get('app', 'orientation', 'landscape')
553         if orientation == 'all':
554             orientation = 'sensor'
555
556         cmd = ['./build.py'
557                 '--dir', root_dir,
558                 '--name', bconfig.get('app', 'title'),
559                 '--package', app['id'],
560                 '--version', bconfig.get('app', 'version'),
561                 '--orientation', orientation,
562                 ]
563
564         perms = bconfig.get('app', 'permissions')
565         for perm in perms.split(','):
566             cmd.extend(['--permission', perm])
567
568         if config.get('app', 'fullscreen') == 0:
569             cmd.append('--window')
570
571         icon = bconfig.get('app', 'icon.filename')
572         if icon:
573             cmd.extend(['--icon', os.path.join(root_dir, icon)])
574
575         cmd.append('release')
576         p = FDroidPopen(cmd, cwd=distdir)
577
578     elif thisbuild['type'] == 'gradle':
579         print "Building Gradle project..."
580         if '@' in thisbuild['gradle']:
581             flavours = thisbuild['gradle'].split('@')[0].split(',')
582             gradle_dir = thisbuild['gradle'].split('@')[1]
583             gradle_dir = os.path.join(root_dir, gradle_dir)
584         else:
585             flavours = thisbuild['gradle'].split(',')
586             gradle_dir = root_dir
587
588
589         if 'compilesdk' in thisbuild:
590             level = thisbuild["compilesdk"].split('-')[1]
591             subprocess.call(['sed', '-i',
592                     's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+level+'@g',
593                     'build.gradle'], cwd=root_dir)
594             if '@' in thisbuild['gradle']:
595                 subprocess.call(['sed', '-i',
596                         's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+level+'@g',
597                         'build.gradle'], cwd=gradle_dir)
598
599         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
600             flavours[0] = ''
601
602         commands = [config['gradle']]
603         if 'preassemble' in thisbuild:
604             commands += thisbuild['preassemble'].split()
605         commands += ['assemble'+''.join(flavours)+'Release']
606
607         p = FDroidPopen(commands, cwd=gradle_dir)
608
609     else:
610         print "Building Ant project..."
611         cmd = ['ant']
612         if 'antcommand' in thisbuild:
613             cmd += [thisbuild['antcommand']]
614         else:
615             cmd += ['release']
616         p = FDroidPopen(cmd, cwd=root_dir)
617
618         bindir = os.path.join(root_dir, 'bin')
619
620     if p.returncode != 0:
621         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
622     print "Successfully built version " + thisbuild['version'] + ' of ' + app['id']
623
624     # Find the apk name in the output...
625     if 'bindir' in thisbuild:
626         bindir = os.path.join(build_dir, thisbuild['bindir'])
627
628     if thisbuild['type'] == 'maven':
629         stdout_apk = '\n'.join([
630             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
631         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
632                 stdout_apk, re.S|re.M)
633         if not m:
634             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
635                     stdout_apk, re.S|re.M)
636         if not m:
637             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
638                     stdout_apk, re.S|re.M)
639         if not m:
640             raise BuildException('Failed to find output')
641         src = m.group(1)
642         src = os.path.join(bindir, src) + '.apk'
643     elif thisbuild['type'] == 'kivy':
644         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
645                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
646     elif thisbuild['type'] == 'gradle':
647         dd = build_dir
648         if 'subdir' in thisbuild:
649             dd = os.path.join(dd, thisbuild['subdir'])
650         if len(flavours) == 1 and flavours[0] == '':
651             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
652         else:
653             name = '-'.join([os.path.basename(dd), '-'.join(flavours), 'release', 'unsigned'])
654         src = os.path.join(dd, 'build', 'apk', name+'.apk')
655     elif thisbuild['type'] == 'ant':
656         stdout_apk = '\n'.join([
657             line for line in p.stdout.splitlines() if '.apk' in line])
658         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
659             re.S|re.M).group(1)
660         src = os.path.join(bindir, 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     print "Checking " + src
669     if not os.path.exists(src):
670         raise BuildException("Unsigned apk is not at expected location of " + src)
671
672     p = FDroidPopen([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:
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     print "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("-l", "--latest", action="store_true", default=False,
787                       help="Build only the latest version of each package")
788     parser.add_option("-s", "--stop", action="store_true", default=False,
789                       help="Make the build stop on exceptions")
790     parser.add_option("-t", "--test", action="store_true", default=False,
791                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
792     parser.add_option("--server", action="store_true", default=False,
793                       help="Use build server")
794     parser.add_option("--resetserver", action="store_true", default=False,
795                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
796     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
797                       help="Specify that we're running on the build server")
798     parser.add_option("-f", "--force", action="store_true", default=False,
799                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
800     parser.add_option("-a", "--all", action="store_true", default=False,
801                       help="Build all applications available")
802     parser.add_option("-w", "--wiki", default=False, action="store_true",
803                       help="Update the wiki")
804     options, args = parser.parse_args()
805
806     # Force --stop with --on-server to get cotrect exit code
807     if options.onserver:
808         options.stop = True
809
810     if options.force and not options.test:
811         raise OptionError("Force is only allowed in test mode", "force")
812
813     return options, args
814
815 options = None
816 config = None
817
818 def main():
819
820     global options, config
821
822     options, args = parse_commandline()
823     if not args and not options.all:
824         raise OptionError("If you really want to build all the apps, use --all", "all")
825
826     config = common.read_config(options)
827
828     if config['build_server_always']:
829         options.server = True
830     if options.resetserver and not options.server:
831         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
832
833     log_dir = 'logs'
834     if not os.path.isdir(log_dir):
835         print "Creating log directory"
836         os.makedirs(log_dir)
837
838     tmp_dir = 'tmp'
839     if not os.path.isdir(tmp_dir):
840         print "Creating temporary directory"
841         os.makedirs(tmp_dir)
842
843     if options.test:
844         output_dir = tmp_dir
845     else:
846         output_dir = 'unsigned'
847         if not os.path.isdir(output_dir):
848             print "Creating output directory"
849             os.makedirs(output_dir)
850
851     if config['archive_older'] != 0:
852         also_check_dir = 'archive'
853     else:
854         also_check_dir = None
855
856     repo_dir = 'repo'
857
858     build_dir = 'build'
859     if not os.path.isdir(build_dir):
860         print "Creating build directory"
861         os.makedirs(build_dir)
862     srclib_dir = os.path.join(build_dir, 'srclib')
863     extlib_dir = os.path.join(build_dir, 'extlib')
864
865     # Get all apps...
866     allapps = metadata.read_metadata(xref=not options.onserver)
867
868     apps = common.read_app_args(args, allapps, True)
869     apps = [app for app in apps if (options.force or not app['Disabled']) and
870             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
871
872     if len(apps) == 0:
873         raise Exception("No apps to process.")
874
875     if options.latest:
876         for app in apps:
877             for build in reversed(app['builds']):
878                 if 'disable' in build:
879                     continue
880                 app['builds'] = [ build ]
881                 break
882
883     if options.wiki:
884         import mwclient
885         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
886                 path=config['wiki_path'])
887         site.login(config['wiki_user'], config['wiki_password'])
888
889     # Build applications...
890     failed_apps = {}
891     build_succeeded = []
892     for app in apps:
893
894         first = True
895
896         for thisbuild in app['builds']:
897             wikilog = None
898             try:
899
900                 # For the first build of a particular app, we need to set up
901                 # the source repo. We can reuse it on subsequent builds, if
902                 # there are any.
903                 if first:
904                     if app['Repo Type'] == 'srclib':
905                         build_dir = os.path.join('build', 'srclib', app['Repo'])
906                     else:
907                         build_dir = os.path.join('build', app['id'])
908
909                     # Set up vcs interface and make sure we have the latest code...
910                     if options.verbose:
911                         print "Getting {0} vcs interface for {1}".format(
912                                 app['Repo Type'], app['Repo'])
913                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
914
915                     first = False
916
917                 if options.verbose:
918                     print "Checking " + thisbuild['version']
919                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
920                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
921                         options.server, options.force, options.onserver):
922                     build_succeeded.append(app)
923                     wikilog = "Build succeeded"
924             except BuildException as be:
925                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
926                 logfile.write(str(be))
927                 logfile.close()
928                 print "Could not build app %s due to BuildException: %s" % (app['id'], be)
929                 if options.stop:
930                     sys.exit(1)
931                 failed_apps[app['id']] = be
932                 wikilog = be.get_wikitext()
933             except VCSException as vcse:
934                 print "VCS error while building app %s: %s" % (app['id'], vcse)
935                 if options.stop:
936                     sys.exit(1)
937                 failed_apps[app['id']] = vcse
938                 wikilog = str(vcse)
939             except Exception as e:
940                 print "Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
941                 if options.stop:
942                     sys.exit(1)
943                 failed_apps[app['id']] = e
944                 wikilog = str(e)
945
946             if options.wiki and wikilog:
947                 try:
948                     newpage = site.Pages[app['id'] + '/lastbuild']
949                     txt = wikilog
950                     if len(txt) > 8192:
951                         txt = txt[-8192:]
952                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
953                     newpage.save(txt, summary='Build log')
954                 except:
955                     print "Error while attempting to publish build log"
956
957     for app in build_succeeded:
958         print "success: %s" % (app['id'])
959
960     if not options.verbose:
961         for fa in failed_apps:
962             print "Build for app %s failed:\n%s" % (fa, failed_apps[fa])
963
964     print "Finished."
965     if len(build_succeeded) > 0:
966         print str(len(build_succeeded)) + ' builds succeeded'
967     if len(failed_apps) > 0:
968         print str(len(failed_apps)) + ' builds failed'
969
970     sys.exit(0)
971
972 if __name__ == "__main__":
973     main()
974