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