chiark / gitweb /
Merge branch 'update-install' 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', basepath=True, prepare=False))
352
353         # If one was used for the main source, add that too.
354         basesrclib = vcs.getsrclib()
355         if basesrclib:
356             srclibpaths.append(basesrclib)
357         for name, number, lib in srclibpaths:
358             logging.info("Sending srclib '%s'" % lib)
359             ftp.chdir(homedir + '/build/srclib')
360             if not os.path.exists(lib):
361                 raise BuildException("Missing srclib directory '" + lib + "'")
362             fv = '.fdroidvcs-' + name
363             ftp.put(os.path.join('build/srclib', fv), fv)
364             send_dir(lib)
365             # Copy the metadata file too...
366             ftp.chdir(homedir + '/srclibs')
367             ftp.put(os.path.join('srclibs', name + '.txt'),
368                     name + '.txt')
369         # Copy the main app source code
370         # (no need if it's a srclib)
371         if (not basesrclib) and os.path.exists(build_dir):
372             ftp.chdir(homedir + '/build')
373             fv = '.fdroidvcs-' + app['id']
374             ftp.put(os.path.join('build', fv), fv)
375             send_dir(build_dir)
376
377         # Execute the build script...
378         logging.info("Starting build...")
379         chan = sshs.get_transport().open_session()
380         chan.get_pty()
381         cmdline = 'python build.py --on-server'
382         if force:
383             cmdline += ' --force --test'
384         if options.verbose:
385             cmdline += ' --verbose'
386         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
387         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
388         output = ''
389         while not chan.exit_status_ready():
390             while chan.recv_ready():
391                 output += chan.recv(1024)
392             time.sleep(0.1)
393         logging.info("...getting exit status")
394         returncode = chan.recv_exit_status()
395         while True:
396             get = chan.recv(1024)
397             if len(get) == 0:
398                 break
399             output += get
400         if returncode != 0:
401             raise BuildException(
402                 "Build.py failed on server for {0}:{1}".format(
403                     app['id'], thisbuild['version']), output)
404
405         # Retrieve the built files...
406         logging.info("Retrieving build output...")
407         if force:
408             ftp.chdir(homedir + '/tmp')
409         else:
410             ftp.chdir(homedir + '/unsigned')
411         apkfile = common.getapkname(app, thisbuild)
412         tarball = common.getsrcname(app, thisbuild)
413         try:
414             ftp.get(apkfile, os.path.join(output_dir, apkfile))
415             if not options.notarball:
416                 ftp.get(tarball, os.path.join(output_dir, tarball))
417         except:
418             raise BuildException(
419                 "Build failed for %s:%s - missing output files".format(
420                     app['id'], thisbuild['version']), output)
421         ftp.close()
422
423     finally:
424
425         # Suspend the build server.
426         release_vm()
427
428
429 def adapt_gradle(build_dir):
430     filename = 'build.gradle'
431     for root, dirs, files in os.walk(build_dir):
432         for filename in files:
433             if not filename.endswith('.gradle'):
434                 continue
435             path = os.path.join(root, filename)
436             if not os.path.isfile(path):
437                 continue
438             logging.debug("Adapting %s at %s" % (filename, path))
439             common.regsub_file(r"""(\s*)buildToolsVersion[\s'"=]+.*""",
440                                r"""\1buildToolsVersion '%s'""" % config['build_tools'],
441                                path)
442
443
444 def capitalize_intact(string):
445     """Like str.capitalize(), but leave the rest of the string intact without
446     switching it to lowercase."""
447     if len(string) == 0:
448         return string
449     if len(string) == 1:
450         return string.upper()
451     return string[0].upper() + string[1:]
452
453
454 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
455     """Do a build locally."""
456
457     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
458         if not thisbuild['ndk_path']:
459             logging.critical("Android NDK version '%s' could not be found!" % thisbuild['ndk'])
460             logging.critical("Configured versions:")
461             for k, v in config['ndk_paths'].iteritems():
462                 if k.endswith("_orig"):
463                     continue
464                 logging.critical("  %s: %s" % (k, v))
465             sys.exit(3)
466         elif not os.path.isdir(thisbuild['ndk_path']):
467             logging.critical("Android NDK '%s' is not a directory!" % thisbuild['ndk_path'])
468             sys.exit(3)
469
470     # Set up environment vars that depend on each build
471     for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
472         common.env[n] = thisbuild['ndk_path']
473
474     common.reset_env_path()
475     # Set up the current NDK to the PATH
476     common.add_to_env_path(thisbuild['ndk_path'])
477
478     # Prepare the source code...
479     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
480                                                   build_dir, srclib_dir,
481                                                   extlib_dir, onserver, refresh)
482
483     # We need to clean via the build tool in case the binary dirs are
484     # different from the default ones
485     p = None
486     gradletasks = []
487     if thisbuild['type'] == 'maven':
488         logging.info("Cleaning Maven project...")
489         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
490
491         if '@' in thisbuild['maven']:
492             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
493             maven_dir = os.path.normpath(maven_dir)
494         else:
495             maven_dir = root_dir
496
497         p = FDroidPopen(cmd, cwd=maven_dir)
498
499     elif thisbuild['type'] == 'gradle':
500
501         logging.info("Cleaning Gradle project...")
502
503         if thisbuild['preassemble']:
504             gradletasks += thisbuild['preassemble']
505
506         flavours = thisbuild['gradle']
507         if flavours == ['yes']:
508             flavours = []
509
510         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
511
512         gradletasks += ['assemble' + flavours_cmd + 'Release']
513
514         adapt_gradle(build_dir)
515         for name, number, libpath in srclibpaths:
516             adapt_gradle(libpath)
517
518         cmd = [config['gradle']]
519         for task in gradletasks:
520             parts = task.split(':')
521             parts[-1] = 'clean' + capitalize_intact(parts[-1])
522             cmd += [':'.join(parts)]
523
524         cmd += ['clean']
525
526         p = FDroidPopen(cmd, cwd=root_dir)
527
528     elif thisbuild['type'] == 'kivy':
529         pass
530
531     elif thisbuild['type'] == 'ant':
532         logging.info("Cleaning Ant project...")
533         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
534
535     if p is not None and p.returncode != 0:
536         raise BuildException("Error cleaning %s:%s" %
537                              (app['id'], thisbuild['version']), p.output)
538
539     for root, dirs, files in os.walk(build_dir):
540         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
541         if 'gradlew' in files:
542             logging.debug("Getting rid of Gradle wrapper stuff in %s" % root)
543             os.remove(os.path.join(root, 'gradlew'))
544             if 'gradlew.bat' in files:
545                 os.remove(os.path.join(root, 'gradlew.bat'))
546             if 'gradle' in dirs:
547                 shutil.rmtree(os.path.join(root, 'gradle'))
548
549     if options.skipscan:
550         if thisbuild['scandelete']:
551             raise BuildException("Refusing to skip source scan since scandelete is present")
552     else:
553         # Scan before building...
554         logging.info("Scanning source for common problems...")
555         count = common.scan_source(build_dir, root_dir, thisbuild)
556         if count > 0:
557             if force:
558                 logging.warn('Scanner found %d problems' % count)
559             else:
560                 raise BuildException("Can't build due to %d errors while scanning" % count)
561
562     if not options.notarball:
563         # Build the source tarball right before we build the release...
564         logging.info("Creating source tarball...")
565         tarname = common.getsrcname(app, thisbuild)
566         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
567
568         def tarexc(f):
569             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
570         tarball.add(build_dir, tarname, exclude=tarexc)
571         tarball.close()
572
573     # Run a build command if one is required...
574     if thisbuild['build']:
575         logging.info("Running 'build' commands in %s" % root_dir)
576         cmd = common.replace_config_vars(thisbuild['build'], thisbuild)
577
578         # Substitute source library paths into commands...
579         for name, number, libpath in srclibpaths:
580             libpath = os.path.relpath(libpath, root_dir)
581             cmd = cmd.replace('$$' + name + '$$', libpath)
582
583         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
584
585         if p.returncode != 0:
586             raise BuildException("Error running build command for %s:%s" %
587                                  (app['id'], thisbuild['version']), p.output)
588
589     # Build native stuff if required...
590     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
591         logging.info("Building the native code")
592         jni_components = thisbuild['buildjni']
593
594         if jni_components == ['yes']:
595             jni_components = ['']
596         cmd = [os.path.join(thisbuild['ndk_path'], "ndk-build"), "-j1"]
597         for d in jni_components:
598             if d:
599                 logging.info("Building native code in '%s'" % d)
600             else:
601                 logging.info("Building native code in the main project")
602             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
603             if os.path.exists(manifest):
604                 # Read and write the whole AM.xml to fix newlines and avoid
605                 # the ndk r8c or later 'wordlist' errors. The outcome of this
606                 # under gnu/linux is the same as when using tools like
607                 # dos2unix, but the native python way is faster and will
608                 # work in non-unix systems.
609                 manifest_text = open(manifest, 'U').read()
610                 open(manifest, 'w').write(manifest_text)
611                 # In case the AM.xml read was big, free the memory
612                 del manifest_text
613             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
614             if p.returncode != 0:
615                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
616
617     p = None
618     # Build the release...
619     if thisbuild['type'] == 'maven':
620         logging.info("Building Maven project...")
621
622         if '@' in thisbuild['maven']:
623             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
624         else:
625             maven_dir = root_dir
626
627         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
628                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
629                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
630                   'package']
631         if thisbuild['target']:
632             target = thisbuild["target"].split('-')[1]
633             common.regsub_file(r'<platform>[0-9]*</platform>',
634                                r'<platform>%s</platform>' % target,
635                                os.path.join(root_dir, 'pom.xml'))
636             if '@' in thisbuild['maven']:
637                 common.regsub_file(r'<platform>[0-9]*</platform>',
638                                    r'<platform>%s</platform>' % target,
639                                    os.path.join(maven_dir, 'pom.xml'))
640
641         p = FDroidPopen(mvncmd, cwd=maven_dir)
642
643         bindir = os.path.join(root_dir, 'target')
644
645     elif thisbuild['type'] == 'kivy':
646         logging.info("Building Kivy project...")
647
648         spec = os.path.join(root_dir, 'buildozer.spec')
649         if not os.path.exists(spec):
650             raise BuildException("Expected to find buildozer-compatible spec at {0}"
651                                  .format(spec))
652
653         defaults = {'orientation': 'landscape', 'icon': '',
654                     'permissions': '', 'android.api': "18"}
655         bconfig = ConfigParser(defaults, allow_no_value=True)
656         bconfig.read(spec)
657
658         distdir = 'python-for-android/dist/fdroid'
659         if os.path.exists(distdir):
660             shutil.rmtree(distdir)
661
662         modules = bconfig.get('app', 'requirements').split(',')
663
664         cmd = 'ANDROIDSDK=' + config['sdk_path']
665         cmd += ' ANDROIDNDK=' + thisbuild['ndk_path']
666         cmd += ' ANDROIDNDKVER=' + thisbuild['ndk']
667         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
668         cmd += ' VIRTUALENV=virtualenv'
669         cmd += ' ./distribute.sh'
670         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
671         cmd += ' -d fdroid'
672         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
673         if p.returncode != 0:
674             raise BuildException("Distribute build failed")
675
676         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
677         if cid != app['id']:
678             raise BuildException("Package ID mismatch between metadata and spec")
679
680         orientation = bconfig.get('app', 'orientation', 'landscape')
681         if orientation == 'all':
682             orientation = 'sensor'
683
684         cmd = ['./build.py'
685                '--dir', root_dir,
686                '--name', bconfig.get('app', 'title'),
687                '--package', app['id'],
688                '--version', bconfig.get('app', 'version'),
689                '--orientation', orientation
690                ]
691
692         perms = bconfig.get('app', 'permissions')
693         for perm in perms.split(','):
694             cmd.extend(['--permission', perm])
695
696         if config.get('app', 'fullscreen') == 0:
697             cmd.append('--window')
698
699         icon = bconfig.get('app', 'icon.filename')
700         if icon:
701             cmd.extend(['--icon', os.path.join(root_dir, icon)])
702
703         cmd.append('release')
704         p = FDroidPopen(cmd, cwd=distdir)
705
706     elif thisbuild['type'] == 'gradle':
707         logging.info("Building Gradle project...")
708
709         # Avoid having to use lintOptions.abortOnError false
710         if thisbuild['gradlepluginver'] >= LooseVersion('0.7'):
711             with open(os.path.join(root_dir, 'build.gradle'), "a") as f:
712                 f.write("\nandroid { lintOptions { checkReleaseBuilds false } }\n")
713
714         commands = [config['gradle']] + gradletasks
715
716         p = FDroidPopen(commands, cwd=root_dir)
717
718     elif thisbuild['type'] == 'ant':
719         logging.info("Building Ant project...")
720         cmd = ['ant']
721         if thisbuild['antcommands']:
722             cmd += thisbuild['antcommands']
723         else:
724             cmd += ['release']
725         p = FDroidPopen(cmd, cwd=root_dir)
726
727         bindir = os.path.join(root_dir, 'bin')
728
729     if p is not None and p.returncode != 0:
730         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
731     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
732
733     if thisbuild['type'] == 'maven':
734         stdout_apk = '\n'.join([
735             line for line in p.output.splitlines() if any(
736                 a in line for a in ('.apk', '.ap_', '.jar'))])
737         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
738                      stdout_apk, re.S | re.M)
739         if not m:
740             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
741                          stdout_apk, re.S | re.M)
742         if not m:
743             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
744                          stdout_apk, re.S | re.M)
745
746         if not m:
747             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
748                          stdout_apk, re.S | re.M)
749         if not m:
750             raise BuildException('Failed to find output')
751         src = m.group(1)
752         src = os.path.join(bindir, src) + '.apk'
753     elif thisbuild['type'] == 'kivy':
754         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
755             bconfig.get('app', 'title'), bconfig.get('app', 'version'))
756     elif thisbuild['type'] == 'gradle':
757
758         if thisbuild['gradlepluginver'] >= LooseVersion('0.11'):
759             apks_dir = os.path.join(root_dir, 'build', 'outputs', 'apk')
760         else:
761             apks_dir = os.path.join(root_dir, 'build', 'apk')
762
763         apks = glob.glob(os.path.join(apks_dir, '*-release-unsigned.apk'))
764         if len(apks) > 1:
765             raise BuildException('More than one resulting apks found in %s' % apks_dir,
766                                  '\n'.join(apks))
767         if len(apks) < 1:
768             raise BuildException('Failed to find gradle output in %s' % apks_dir)
769         src = apks[0]
770     elif thisbuild['type'] == 'ant':
771         stdout_apk = '\n'.join([
772             line for line in p.output.splitlines() if '.apk' in line])
773         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
774                        re.S | re.M).group(1)
775         src = os.path.join(bindir, src)
776     elif thisbuild['type'] == 'raw':
777         src = os.path.join(root_dir, thisbuild['output'])
778         src = os.path.normpath(src)
779
780     # Make sure it's not debuggable...
781     if common.isApkDebuggable(src, config):
782         raise BuildException("APK is debuggable")
783
784     # By way of a sanity check, make sure the version and version
785     # code in our new apk match what we expect...
786     logging.debug("Checking " + src)
787     if not os.path.exists(src):
788         raise BuildException("Unsigned apk is not at expected location of " + src)
789
790     p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
791
792     vercode = None
793     version = None
794     foundid = None
795     nativecode = None
796     for line in p.output.splitlines():
797         if line.startswith("package:"):
798             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
799             m = pat.match(line)
800             if m:
801                 foundid = m.group(1)
802             pat = re.compile(".*versionCode='([0-9]*)'.*")
803             m = pat.match(line)
804             if m:
805                 vercode = m.group(1)
806             pat = re.compile(".*versionName='([^']*)'.*")
807             m = pat.match(line)
808             if m:
809                 version = m.group(1)
810         elif line.startswith("native-code:"):
811             nativecode = line[12:]
812
813     # Ignore empty strings or any kind of space/newline chars that we don't
814     # care about
815     if nativecode is not None:
816         nativecode = nativecode.strip()
817         nativecode = None if not nativecode else nativecode
818
819     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
820         if nativecode is None:
821             raise BuildException("Native code should have been built but none was packaged")
822     if thisbuild['novcheck']:
823         vercode = thisbuild['vercode']
824         version = thisbuild['version']
825     if not version or not vercode:
826         raise BuildException("Could not find version information in build in output")
827     if not foundid:
828         raise BuildException("Could not find package ID in output")
829     if foundid != app['id']:
830         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
831
832     # Some apps (e.g. Timeriffic) have had the bonkers idea of
833     # including the entire changelog in the version number. Remove
834     # it so we can compare. (TODO: might be better to remove it
835     # before we compile, in fact)
836     index = version.find(" //")
837     if index != -1:
838         version = version[:index]
839
840     if (version != thisbuild['version'] or
841             vercode != thisbuild['vercode']):
842         raise BuildException(("Unexpected version/version code in output;"
843                               " APK: '%s' / '%s', "
844                               " Expected: '%s' / '%s'")
845                              % (version, str(vercode), thisbuild['version'],
846                                 str(thisbuild['vercode']))
847                              )
848
849     # Add information for 'fdroid verify' to be able to reproduce the build
850     # environment.
851     if onserver:
852         metadir = os.path.join(tmp_dir, 'META-INF')
853         if not os.path.exists(metadir):
854             os.mkdir(metadir)
855         homedir = os.path.expanduser('~')
856         for fn in ['buildserverid', 'fdroidserverid']:
857             shutil.copyfile(os.path.join(homedir, fn),
858                             os.path.join(metadir, fn))
859             subprocess.call(['jar', 'uf', os.path.abspath(src),
860                              'META-INF/' + fn], cwd=tmp_dir)
861
862     # Copy the unsigned apk to our destination directory for further
863     # processing (by publish.py)...
864     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
865     shutil.copyfile(src, dest)
866
867     # Move the source tarball into the output directory...
868     if output_dir != tmp_dir and not options.notarball:
869         shutil.move(os.path.join(tmp_dir, tarname),
870                     os.path.join(output_dir, tarname))
871
872
873 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
874              tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
875     """
876     Build a particular version of an application, if it needs building.
877
878     :param output_dir: The directory where the build output will go. Usually
879        this is the 'unsigned' directory.
880     :param repo_dir: The repo directory - used for checking if the build is
881        necessary.
882     :paaram also_check_dir: An additional location for checking if the build
883        is necessary (usually the archive repo)
884     :param test: True if building in test mode, in which case the build will
885        always happen, even if the output already exists. In test mode, the
886        output directory should be a temporary location, not any of the real
887        ones.
888
889     :returns: True if the build was done, False if it wasn't necessary.
890     """
891
892     dest_apk = common.getapkname(app, thisbuild)
893
894     dest = os.path.join(output_dir, dest_apk)
895     dest_repo = os.path.join(repo_dir, dest_apk)
896
897     if not test:
898         if os.path.exists(dest) or os.path.exists(dest_repo):
899             return False
900
901         if also_check_dir:
902             dest_also = os.path.join(also_check_dir, dest_apk)
903             if os.path.exists(dest_also):
904                 return False
905
906     if thisbuild['disable'] and not options.force:
907         return False
908
909     logging.info("Building version %s (%s) of %s" % (
910         thisbuild['version'], thisbuild['vercode'], app['id']))
911
912     if server:
913         # When using server mode, still keep a local cache of the repo, by
914         # grabbing the source now.
915         vcs.gotorevision(thisbuild['commit'])
916
917         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
918     else:
919         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
920     return True
921
922
923 def parse_commandline():
924     """Parse the command line. Returns options, args."""
925
926     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
927     parser.add_option("-v", "--verbose", action="store_true", default=False,
928                       help="Spew out even more information than normal")
929     parser.add_option("-q", "--quiet", action="store_true", default=False,
930                       help="Restrict output to warnings and errors")
931     parser.add_option("-l", "--latest", action="store_true", default=False,
932                       help="Build only the latest version of each package")
933     parser.add_option("-s", "--stop", action="store_true", default=False,
934                       help="Make the build stop on exceptions")
935     parser.add_option("-t", "--test", action="store_true", default=False,
936                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
937     parser.add_option("--server", action="store_true", default=False,
938                       help="Use build server")
939     parser.add_option("--resetserver", action="store_true", default=False,
940                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
941     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
942                       help="Specify that we're running on the build server")
943     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
944                       help="Skip scanning the source code for binaries and other problems")
945     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
946                       help="Don't create a source tarball, useful when testing a build")
947     parser.add_option("--no-refresh", dest="refresh", action="store_false", default=True,
948                       help="Don't refresh the repository, useful when testing a build with no internet connection")
949     parser.add_option("-f", "--force", action="store_true", default=False,
950                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
951     parser.add_option("-a", "--all", action="store_true", default=False,
952                       help="Build all applications available")
953     parser.add_option("-w", "--wiki", default=False, action="store_true",
954                       help="Update the wiki")
955     options, args = parser.parse_args()
956
957     # Force --stop with --on-server to get correct exit code
958     if options.onserver:
959         options.stop = True
960
961     if options.force and not options.test:
962         raise OptionError("Force is only allowed in test mode", "force")
963
964     return options, args
965
966 options = None
967 config = None
968
969
970 def main():
971
972     global options, config
973
974     options, args = parse_commandline()
975     if not args and not options.all:
976         raise OptionError("If you really want to build all the apps, use --all", "all")
977
978     config = common.read_config(options)
979
980     if config['build_server_always']:
981         options.server = True
982     if options.resetserver and not options.server:
983         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
984
985     log_dir = 'logs'
986     if not os.path.isdir(log_dir):
987         logging.info("Creating log directory")
988         os.makedirs(log_dir)
989
990     tmp_dir = 'tmp'
991     if not os.path.isdir(tmp_dir):
992         logging.info("Creating temporary directory")
993         os.makedirs(tmp_dir)
994
995     if options.test:
996         output_dir = tmp_dir
997     else:
998         output_dir = 'unsigned'
999         if not os.path.isdir(output_dir):
1000             logging.info("Creating output directory")
1001             os.makedirs(output_dir)
1002
1003     if config['archive_older'] != 0:
1004         also_check_dir = 'archive'
1005     else:
1006         also_check_dir = None
1007
1008     repo_dir = 'repo'
1009
1010     build_dir = 'build'
1011     if not os.path.isdir(build_dir):
1012         logging.info("Creating build directory")
1013         os.makedirs(build_dir)
1014     srclib_dir = os.path.join(build_dir, 'srclib')
1015     extlib_dir = os.path.join(build_dir, 'extlib')
1016
1017     # Read all app and srclib metadata
1018     allapps = metadata.read_metadata(xref=not options.onserver)
1019
1020     apps = common.read_app_args(args, allapps, True)
1021     for appid, app in apps.items():
1022         if (app['Disabled'] and not options.force) or not app['Repo Type'] or not app['builds']:
1023             del apps[appid]
1024
1025     if not apps:
1026         raise FDroidException("No apps to process.")
1027
1028     if options.latest:
1029         for app in apps.itervalues():
1030             for build in reversed(app['builds']):
1031                 if build['disable'] and not options.force:
1032                     continue
1033                 app['builds'] = [build]
1034                 break
1035
1036     if options.wiki:
1037         import mwclient
1038         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1039                              path=config['wiki_path'])
1040         site.login(config['wiki_user'], config['wiki_password'])
1041
1042     # Build applications...
1043     failed_apps = {}
1044     build_succeeded = []
1045     for appid, app in apps.iteritems():
1046
1047         first = True
1048
1049         for thisbuild in app['builds']:
1050             wikilog = None
1051             try:
1052
1053                 # For the first build of a particular app, we need to set up
1054                 # the source repo. We can reuse it on subsequent builds, if
1055                 # there are any.
1056                 if first:
1057                     if app['Repo Type'] == 'srclib':
1058                         build_dir = os.path.join('build', 'srclib', app['Repo'])
1059                     else:
1060                         build_dir = os.path.join('build', appid)
1061
1062                     # Set up vcs interface and make sure we have the latest code...
1063                     logging.debug("Getting {0} vcs interface for {1}"
1064                                   .format(app['Repo Type'], app['Repo']))
1065                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
1066
1067                     first = False
1068
1069                 logging.debug("Checking " + thisbuild['version'])
1070                 if trybuild(app, thisbuild, build_dir, output_dir,
1071                             also_check_dir, srclib_dir, extlib_dir,
1072                             tmp_dir, repo_dir, vcs, options.test,
1073                             options.server, options.force,
1074                             options.onserver, options.refresh):
1075
1076                     if app.get('Binaries', None):
1077                         # This is an app where we build from source, and
1078                         # verify the apk contents against a developer's
1079                         # binary. We get that binary now, and save it
1080                         # alongside our built one in the 'unsigend'
1081                         # directory.
1082                         url = app['Binaries']
1083                         url = url.replace('%v', thisbuild['version'])
1084                         url = url.replace('%c', str(thisbuild['vercode']))
1085                         logging.info("...retrieving " + url)
1086                         of = "{0}_{1}.apk.binary".format(app['id'], thisbuild['vercode'])
1087                         of = os.path.join(output_dir, of)
1088                         common.download_file(url, local_filename=of)
1089
1090                     build_succeeded.append(app)
1091                     wikilog = "Build succeeded"
1092             except BuildException as be:
1093                 logfile = open(os.path.join(log_dir, appid + '.log'), 'a+')
1094                 logfile.write(str(be))
1095                 logfile.close()
1096                 print("Could not build app %s due to BuildException: %s" % (appid, be))
1097                 if options.stop:
1098                     sys.exit(1)
1099                 failed_apps[appid] = be
1100                 wikilog = be.get_wikitext()
1101             except VCSException as vcse:
1102                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1103                 logging.error("VCS error while building app %s: %s" % (
1104                     appid, reason))
1105                 if options.stop:
1106                     sys.exit(1)
1107                 failed_apps[appid] = vcse
1108                 wikilog = str(vcse)
1109             except Exception as e:
1110                 logging.error("Could not build app %s due to unknown error: %s" % (
1111                     appid, traceback.format_exc()))
1112                 if options.stop:
1113                     sys.exit(1)
1114                 failed_apps[appid] = e
1115                 wikilog = str(e)
1116
1117             if options.wiki and wikilog:
1118                 try:
1119                     # Write a page with the last build log for this version code
1120                     lastbuildpage = appid + '/lastbuild_' + thisbuild['vercode']
1121                     newpage = site.Pages[lastbuildpage]
1122                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1123                     newpage.save(txt, summary='Build log')
1124                     # Redirect from /lastbuild to the most recent build log
1125                     newpage = site.Pages[appid + '/lastbuild']
1126                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1127                 except:
1128                     logging.error("Error while attempting to publish build log")
1129
1130     for app in build_succeeded:
1131         logging.info("success: %s" % (app['id']))
1132
1133     if not options.verbose:
1134         for fa in failed_apps:
1135             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1136
1137     logging.info("Finished.")
1138     if len(build_succeeded) > 0:
1139         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1140     if len(failed_apps) > 0:
1141         logging.info(str(len(failed_apps)) + ' builds failed')
1142
1143     sys.exit(0)
1144
1145 if __name__ == "__main__":
1146     main()