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