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