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