chiark / gitweb /
Make matching of build types easier
[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 = 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         cmdline = 'python build.py --on-server'
330         if force:
331             cmdline += ' --force --test'
332         if options.verbose:
333             cmdline += ' --verbose'
334         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
335         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
336         output = ''
337         error = ''
338         while not chan.exit_status_ready():
339             while chan.recv_ready():
340                 output += chan.recv(1024)
341             while chan.recv_stderr_ready():
342                 error += chan.recv_stderr(1024)
343             time.sleep(0.1)
344         print "...getting exit status"
345         returncode = chan.recv_exit_status()
346         while True:
347             get = chan.recv(1024)
348             if len(get) == 0:
349                 break
350             output += get
351         while True:
352             get = chan.recv_stderr(1024)
353             if len(get) == 0:
354                 break
355             error += get
356         if returncode != 0:
357             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output, error)
358
359         # Retrieve the built files...
360         print "Retrieving build output..."
361         if force:
362             ftp.chdir('/home/vagrant/tmp')
363         else:
364             ftp.chdir('/home/vagrant/unsigned')
365         apkfile = common.getapkname(app,thisbuild)
366         tarball = common.getsrcname(app,thisbuild)
367         try:
368             ftp.get(apkfile, os.path.join(output_dir, apkfile))
369             ftp.get(tarball, os.path.join(output_dir, tarball))
370         except:
371             raise BuildException("Build failed for %s:%s - missing output files" % (app['id'], thisbuild['version']), output, error)
372         ftp.close()
373
374     finally:
375
376         # Suspend the build server.
377         print "Suspending build server"
378         subprocess.call(['vagrant', 'suspend'], cwd='builder')
379
380 def adapt_gradle(path):
381     for root, dirs, files in os.walk(path):
382         for f in files:
383             if f == 'build.gradle':
384                 if options.verbose:
385                     print "Adapting build.gradle at %s" % path
386
387                 subprocess.call(['sed', '-i',
388                         's@buildToolsVersion[ ]*["\\\'][0-9\.]*["\\\']@buildToolsVersion "'
389                         + config['build_tools'] + '"@g', path])
390                 break
391
392
393 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
394     """Do a build locally."""
395
396     # Prepare the source code...
397     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
398             build_dir, srclib_dir, extlib_dir, onserver)
399
400     # We need to clean via the build tool in case the binary dirs are
401     # different from the default ones
402     p = None
403     if thisbuild['type'] == 'maven':
404         print "Cleaning Maven project..."
405         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
406
407         if '@' in thisbuild['maven']:
408             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
409             maven_dir = os.path.normpath(maven_dir)
410         else:
411             maven_dir = root_dir
412
413         p = FDroidPopen(cmd, cwd=maven_dir)
414
415     elif thisbuild['type'] == 'gradle':
416         print "Cleaning Gradle project..."
417         cmd = [config['gradle'], 'clean']
418
419         if '@' in thisbuild['gradle']:
420             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@',1)[1])
421             gradle_dir = os.path.normpath(gradle_dir)
422         else:
423             gradle_dir = root_dir
424
425         p = FDroidPopen(cmd, cwd=gradle_dir)
426
427     elif thisbuild['type'] == 'kivy':
428         pass
429
430     elif thisbuild['type'] == 'ant':
431         print "Cleaning Ant project..."
432         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
433
434     if p is not None and p.returncode != 0:
435         raise BuildException("Error cleaning %s:%s" %
436                 (app['id'], thisbuild['version']), p.stdout, p.stderr)
437
438     # Scan before building...
439     print "Scanning source for common problems..."
440     buildprobs = common.scan_source(build_dir, root_dir, thisbuild)
441     if len(buildprobs) > 0:
442         print 'Scanner found ' + str(len(buildprobs)) + ' problems:'
443         for problem in buildprobs:
444             print '    %s' % problem
445         if not force:
446             raise BuildException("Can't build due to " +
447                 str(len(buildprobs)) + " scanned problems")
448
449     # Build the source tarball right before we build the release...
450     print "Creating source tarball..."
451     tarname = common.getsrcname(app,thisbuild)
452     tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
453     def tarexc(f):
454         for vcs_dir in ['.svn', '.git', '.hg', '.bzr']:
455             if f.endswith(vcs_dir):
456                 return True
457         return False
458     tarball.add(build_dir, tarname, exclude=tarexc)
459     tarball.close()
460
461     # Run a build command if one is required...
462     if 'build' in thisbuild:
463         cmd = common.replace_config_vars(thisbuild['build'])
464         # Substitute source library paths into commands...
465         for name, number, libpath in srclibpaths:
466             libpath = os.path.relpath(libpath, root_dir)
467             cmd = cmd.replace('$$' + name + '$$', libpath)
468         if options.verbose:
469             print "Running 'build' commands in %s" % root_dir
470
471         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
472
473         if p.returncode != 0:
474             raise BuildException("Error running build command for %s:%s" %
475                     (app['id'], thisbuild['version']), p.stdout, p.stderr)
476
477     # Build native stuff if required...
478     if thisbuild.get('buildjni') not in (None, 'no'):
479         print "Building native libraries..."
480         jni_components = thisbuild.get('buildjni')
481         if jni_components == 'yes':
482             jni_components = ['']
483         else:
484             jni_components = [c.strip() for c in jni_components.split(';')]
485         ndkbuild = os.path.join(config['ndk_path'], "ndk-build")
486         for d in jni_components:
487             if options.verbose:
488                 print "Running ndk-build in " + root_dir + '/' + d
489             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
490             if os.path.exists(manifest):
491                 # Read and write the whole AM.xml to fix newlines and avoid
492                 # the ndk r8c or later 'wordlist' errors. The outcome of this
493                 # under gnu/linux is the same as when using tools like
494                 # dos2unix, but the native python way is faster and will
495                 # work in non-unix systems.
496                 manifest_text = open(manifest, 'U').read()
497                 open(manifest, 'w').write(manifest_text)
498                 # In case the AM.xml read was big, free the memory
499                 del manifest_text
500             p = FDroidPopen([ndkbuild], cwd=os.path.join(root_dir,d))
501             if p.returncode != 0:
502                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout, p.stderr)
503
504     p = None
505     # Build the release...
506     if thisbuild['type'] == 'maven':
507         print "Building Maven project..."
508
509         if '@' in thisbuild['maven']:
510             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
511         else:
512             maven_dir = root_dir
513
514         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
515                 '-Dandroid.sign.debug=false', '-Dandroid.release=true', 'package']
516         if 'target' in thisbuild:
517             target = thisbuild["target"].split('-')[1]
518             subprocess.call(['sed', '-i',
519                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
520                     'pom.xml'], cwd=root_dir)
521             if '@' in thisbuild['maven']:
522                 subprocess.call(['sed', '-i',
523                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
524                         'pom.xml'], cwd=maven_dir)
525
526         if 'mvnflags' in thisbuild:
527             mvncmd += thisbuild['mvnflags']
528
529         p = FDroidPopen(mvncmd, cwd=maven_dir)
530
531         bindir = os.path.join(root_dir, 'target')
532
533     elif thisbuild['type'] == 'kivy':
534         print "Building Kivy project..."
535
536         spec = os.path.join(root_dir, 'buildozer.spec')
537         if not os.path.exists(spec):
538             raise BuildException("Expected to find buildozer-compatible spec at {0}"
539                     .format(spec))
540
541         defaults = {'orientation': 'landscape', 'icon': '',
542                 'permissions': '', 'android.api': "18"}
543         bconfig = ConfigParser(defaults, allow_no_value=True)
544         bconfig.read(spec)
545
546         distdir = 'python-for-android/dist/fdroid'
547         if os.path.exists(distdir):
548             shutil.rmtree(distdir)
549
550         modules = bconfig.get('app', 'requirements').split(',')
551
552         cmd = 'ANDROIDSDK=' + config['sdk_path']
553         cmd += ' ANDROIDNDK=' + config['ndk_path']
554         cmd += ' ANDROIDNDKVER=r9'
555         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
556         cmd += ' VIRTUALENV=virtualenv'
557         cmd += ' ./distribute.sh'
558         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
559         cmd += ' -d fdroid'
560         if subprocess.call(cmd, cwd='python-for-android', shell=True) != 0:
561             raise BuildException("Distribute build failed")
562
563         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
564         if cid != app['id']:
565             raise BuildException("Package ID mismatch between metadata and spec")
566
567         orientation = bconfig.get('app', 'orientation', 'landscape')
568         if orientation == 'all':
569             orientation = 'sensor'
570
571         cmd = ['./build.py'
572                 '--dir', root_dir,
573                 '--name', bconfig.get('app', 'title'),
574                 '--package', app['id'],
575                 '--version', bconfig.get('app', 'version'),
576                 '--orientation', orientation,
577                 ]
578
579         perms = bconfig.get('app', 'permissions')
580         for perm in perms.split(','):
581             cmd.extend(['--permission', perm])
582
583         if config.get('app', 'fullscreen') == 0:
584             cmd.append('--window')
585
586         icon = bconfig.get('app', 'icon.filename')
587         if icon:
588             cmd.extend(['--icon', os.path.join(root_dir, icon)])
589
590         cmd.append('release')
591         p = FDroidPopen(cmd, cwd=distdir)
592
593     elif thisbuild['type'] == 'gradle':
594         print "Building Gradle project..."
595         if '@' in thisbuild['gradle']:
596             flavour = thisbuild['gradle'].split('@')[0]
597             gradle_dir = thisbuild['gradle'].split('@')[1]
598             gradle_dir = os.path.join(root_dir, gradle_dir)
599         else:
600             flavour = thisbuild['gradle']
601             gradle_dir = root_dir
602
603
604         if 'compilesdk' in thisbuild:
605             level = thisbuild["compilesdk"].split('-')[1]
606             subprocess.call(['sed', '-i',
607                     's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+level+'@g',
608                     'build.gradle'], cwd=root_dir)
609             if '@' in thisbuild['gradle']:
610                 subprocess.call(['sed', '-i',
611                         's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+level+'@g',
612                         'build.gradle'], cwd=gradle_dir)
613
614         adapt_gradle(gradle_dir)
615
616         for name, number, libpath in srclibpaths:
617             adapt_gradle(libpath)
618
619         if flavour in ['main', 'yes', '']:
620             flavour = ''
621
622         commands = [config['gradle']]
623         if 'preassemble' in thisbuild:
624             for task in thisbuild['preassemble'].split():
625                 commands.append(task)
626         commands += ['assemble'+flavour+'Release']
627
628         p = FDroidPopen(commands, cwd=gradle_dir)
629
630     else:
631         print "Building Ant project..."
632         cmd = ['ant']
633         if 'antcommand' in thisbuild:
634             cmd += [thisbuild['antcommand']]
635         else:
636             cmd += ['release']
637         p = FDroidPopen(cmd, cwd=root_dir)
638
639         bindir = os.path.join(root_dir, 'bin')
640
641     if p.returncode != 0:
642         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout, p.stderr)
643     print "Successfully built version " + thisbuild['version'] + ' of ' + app['id']
644
645     # Find the apk name in the output...
646     if 'bindir' in thisbuild:
647         bindir = os.path.join(build_dir, thisbuild['bindir'])
648
649     if thisbuild['type'] == 'maven':
650         stdout_apk = '\n'.join([
651             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
652         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
653                 stdout_apk, re.S|re.M)
654         if not m:
655             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
656                     stdout_apk, re.S|re.M)
657         if not m:
658             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
659                     stdout_apk, re.S|re.M)
660         if not m:
661             raise BuildException('Failed to find output')
662         src = m.group(1)
663         src = os.path.join(bindir, src) + '.apk'
664     elif thisbuild['type'] == 'kivy':
665         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
666                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
667     elif thisbuild['type'] == 'gradle':
668         dd = build_dir
669         if 'subdir' in thisbuild:
670             dd = os.path.join(dd, thisbuild['subdir'])
671         if flavour in ['main', 'yes', '']:
672             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
673         else:
674             name = '-'.join([os.path.basename(dd), flavour, 'release', 'unsigned'])
675         src = os.path.join(dd, 'build', 'apk', name+'.apk')
676     else:
677         stdout_apk = '\n'.join([
678             line for line in p.stdout.splitlines() if '.apk' in line])
679         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
680             re.S|re.M).group(1)
681         src = os.path.join(bindir, src)
682
683     # Make sure it's not debuggable...
684     if common.isApkDebuggable(src, config):
685         raise BuildException("APK is debuggable")
686
687     # By way of a sanity check, make sure the version and version
688     # code in our new apk match what we expect...
689     print "Checking " + src
690     if not os.path.exists(src):
691         raise BuildException("Unsigned apk is not at expected location of " + src)
692
693     p = subprocess.Popen([os.path.join(config['sdk_path'],
694                         'build-tools', config['build_tools'], 'aapt'),
695                         'dump', 'badging', src],
696                         stdout=subprocess.PIPE)
697     output = p.communicate()[0]
698
699     vercode = None
700     version = None
701     foundid = None
702     for line in output.splitlines():
703         if line.startswith("package:"):
704             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
705             m = pat.match(line)
706             if m:
707                 foundid = m.group(1)
708             pat = re.compile(".*versionCode='([0-9]*)'.*")
709             m = pat.match(line)
710             if m:
711                 vercode = m.group(1)
712             pat = re.compile(".*versionName='([^']*)'.*")
713             m = pat.match(line)
714             if m:
715                 version = m.group(1)
716
717     if thisbuild['novcheck']:
718         vercode = thisbuild['vercode']
719         version = thisbuild['version']
720     if not version or not vercode:
721         raise BuildException("Could not find version information in build in output")
722     if not foundid:
723         raise BuildException("Could not find package ID in output")
724     if foundid != app['id']:
725         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
726
727     # Some apps (e.g. Timeriffic) have had the bonkers idea of
728     # including the entire changelog in the version number. Remove
729     # it so we can compare. (TODO: might be better to remove it
730     # before we compile, in fact)
731     index = version.find(" //")
732     if index != -1:
733         version = version[:index]
734
735     if (version != thisbuild['version'] or
736             vercode != thisbuild['vercode']):
737         raise BuildException(("Unexpected version/version code in output;"
738                              " APK: '%s' / '%s', "
739                              " Expected: '%s' / '%s'")
740                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
741                             )
742
743     # Copy the unsigned apk to our destination directory for further
744     # processing (by publish.py)...
745     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
746     shutil.copyfile(src, dest)
747
748     # Move the source tarball into the output directory...
749     if output_dir != tmp_dir:
750         shutil.move(os.path.join(tmp_dir, tarname),
751             os.path.join(output_dir, tarname))
752
753
754 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
755         tmp_dir, repo_dir, vcs, test, server, force, onserver):
756     """
757     Build a particular version of an application, if it needs building.
758
759     :param output_dir: The directory where the build output will go. Usually
760        this is the 'unsigned' directory.
761     :param repo_dir: The repo directory - used for checking if the build is
762        necessary.
763     :paaram also_check_dir: An additional location for checking if the build
764        is necessary (usually the archive repo)
765     :param test: True if building in test mode, in which case the build will
766        always happen, even if the output already exists. In test mode, the
767        output directory should be a temporary location, not any of the real
768        ones.
769
770     :returns: True if the build was done, False if it wasn't necessary.
771     """
772
773     dest_apk = common.getapkname(app, thisbuild)
774
775     dest = os.path.join(output_dir, dest_apk)
776     dest_repo = os.path.join(repo_dir, dest_apk)
777
778     if not test:
779         if os.path.exists(dest) or os.path.exists(dest_repo):
780             return False
781
782         if also_check_dir:
783             dest_also = os.path.join(also_check_dir, dest_apk)
784             if os.path.exists(dest_also):
785                 return False
786
787     if 'disable' in thisbuild:
788         return False
789
790     print "Building version " + thisbuild['version'] + ' of ' + app['id']
791
792     if server:
793         # When using server mode, still keep a local cache of the repo, by
794         # grabbing the source now.
795         vcs.gotorevision(thisbuild['commit'])
796
797         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
798     else:
799         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
800     return True
801
802
803 def parse_commandline():
804     """Parse the command line. Returns options, args."""
805
806     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
807     parser.add_option("-v", "--verbose", action="store_true", default=False,
808                       help="Spew out even more information than normal")
809     parser.add_option("-l", "--latest", action="store_true", default=False,
810                       help="Build only the latest version of each package")
811     parser.add_option("-s", "--stop", action="store_true", default=False,
812                       help="Make the build stop on exceptions")
813     parser.add_option("-t", "--test", action="store_true", default=False,
814                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
815     parser.add_option("--server", action="store_true", default=False,
816                       help="Use build server")
817     parser.add_option("--resetserver", action="store_true", default=False,
818                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
819     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
820                       help="Specify that we're running on the build server")
821     parser.add_option("-f", "--force", action="store_true", default=False,
822                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
823     parser.add_option("-a", "--all", action="store_true", default=False,
824                       help="Build all applications available")
825     parser.add_option("-w", "--wiki", default=False, action="store_true",
826                       help="Update the wiki")
827     options, args = parser.parse_args()
828
829     # Force --stop with --on-server to get cotrect exit code
830     if options.onserver:
831         options.stop = True
832
833     if options.force and not options.test:
834         raise OptionError("Force is only allowed in test mode", "force")
835
836     return options, args
837
838 options = None
839 config = None
840
841 def main():
842
843     global options, config
844
845     options, args = parse_commandline()
846     if not args and not options.all:
847         raise OptionError("If you really want to build all the apps, use --all", "all")
848
849     config = common.read_config(options)
850
851     if config['build_server_always']:
852         options.server = True
853     if options.resetserver and not options.server:
854         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
855
856     log_dir = 'logs'
857     if not os.path.isdir(log_dir):
858         print "Creating log directory"
859         os.makedirs(log_dir)
860
861     tmp_dir = 'tmp'
862     if not os.path.isdir(tmp_dir):
863         print "Creating temporary directory"
864         os.makedirs(tmp_dir)
865
866     if options.test:
867         output_dir = tmp_dir
868     else:
869         output_dir = 'unsigned'
870         if not os.path.isdir(output_dir):
871             print "Creating output directory"
872             os.makedirs(output_dir)
873
874     if config['archive_older'] != 0:
875         also_check_dir = 'archive'
876     else:
877         also_check_dir = None
878
879     repo_dir = 'repo'
880
881     build_dir = 'build'
882     if not os.path.isdir(build_dir):
883         print "Creating build directory"
884         os.makedirs(build_dir)
885     srclib_dir = os.path.join(build_dir, 'srclib')
886     extlib_dir = os.path.join(build_dir, 'extlib')
887
888     # Get all apps...
889     allapps = metadata.read_metadata(xref=not options.onserver)
890
891     apps = common.read_app_args(args, allapps, True)
892     apps = [app for app in apps if (options.force or not app['Disabled']) and
893             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
894
895     if len(apps) == 0:
896         raise Exception("No apps to process.")
897
898     if options.latest:
899         for app in apps:
900             app['builds'] = app['builds'][-1:]
901
902     if options.wiki:
903         import mwclient
904         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
905                 path=config['wiki_path'])
906         site.login(config['wiki_user'], config['wiki_password'])
907
908     # Build applications...
909     failed_apps = {}
910     build_succeeded = []
911     for app in apps:
912
913         first = True
914
915         for thisbuild in app['builds']:
916             wikilog = None
917             try:
918
919                 # For the first build of a particular app, we need to set up
920                 # the source repo. We can reuse it on subsequent builds, if
921                 # there are any.
922                 if first:
923                     if app['Repo Type'] == 'srclib':
924                         build_dir = os.path.join('build', 'srclib', app['Repo'])
925                     else:
926                         build_dir = os.path.join('build', app['id'])
927
928                     # Set up vcs interface and make sure we have the latest code...
929                     if options.verbose:
930                         print "Getting {0} vcs interface for {1}".format(
931                                 app['Repo Type'], app['Repo'])
932                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
933
934                     first = False
935
936                 if options.verbose:
937                     print "Checking " + thisbuild['version']
938                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
939                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
940                         options.server, options.force, options.onserver):
941                     build_succeeded.append(app)
942                     wikilog = "Build succeeded"
943             except BuildException as be:
944                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
945                 logfile.write(str(be))
946                 logfile.close()
947                 print "Could not build app %s due to BuildException: %s" % (app['id'], be)
948                 if options.stop:
949                     sys.exit(1)
950                 failed_apps[app['id']] = be
951                 wikilog = be.get_wikitext()
952             except VCSException as vcse:
953                 print "VCS error while building app %s: %s" % (app['id'], vcse)
954                 if options.stop:
955                     sys.exit(1)
956                 failed_apps[app['id']] = vcse
957                 wikilog = str(vcse)
958             except Exception as e:
959                 print "Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
960                 if options.stop:
961                     sys.exit(1)
962                 failed_apps[app['id']] = e
963                 wikilog = str(e)
964
965             if options.wiki and wikilog:
966                 try:
967                     newpage = site.Pages[app['id'] + '/lastbuild']
968                     txt = wikilog
969                     if len(txt) > 8192:
970                         txt = txt[-8192:]
971                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + txt
972                     newpage.save(wikilog, summary='Build log')
973                 except:
974                     print "Error while attempting to publish build log"
975
976     for app in build_succeeded:
977         print "success: %s" % (app['id'])
978
979     if not options.verbose:
980         for fa in failed_apps:
981             print "Build for app %s failed:\n%s" % (fa, failed_apps[fa])
982
983     print "Finished."
984     if len(build_succeeded) > 0:
985         print str(len(build_succeeded)) + ' builds succeeded'
986     if len(failed_apps) > 0:
987         print str(len(failed_apps)) + ' builds failed'
988
989     sys.exit(0)
990
991 if __name__ == "__main__":
992     main()
993