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