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