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