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