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