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