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