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