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