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