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