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