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