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