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