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