chiark / gitweb /
Apk output path is build/outputs/apk/*.apk since 0.11
[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.stdout)
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.stdout:
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.stdout)
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.stdout:
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         if '@' in thisbuild['gradle']:
477             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@', 1)[1])
478             gradle_dir = os.path.normpath(gradle_dir)
479         else:
480             gradle_dir = root_dir
481
482         adapt_gradle(build_dir)
483         for name, number, libpath in srclibpaths:
484             adapt_gradle(libpath)
485
486         p = FDroidPopen(cmd, cwd=gradle_dir)
487
488     elif thisbuild['type'] == 'kivy':
489         pass
490
491     elif thisbuild['type'] == 'ant':
492         logging.info("Cleaning Ant project...")
493         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
494
495     if p is not None and p.returncode != 0:
496         raise BuildException("Error cleaning %s:%s" %
497                              (app['id'], thisbuild['version']), p.stdout)
498
499     logging.info("Getting rid of Gradle wrapper binaries...")
500     for root, dirs, files in os.walk(build_dir):
501         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
502         if 'gradlew' in files:
503             os.remove(os.path.join(root, 'gradlew'))
504             if 'gradlew.bat' in files:
505                 os.remove(os.path.join(root, 'gradlew.bat'))
506             if 'gradle' in dirs:
507                 shutil.rmtree(os.path.join(root, 'gradle'))
508
509     if not options.skipscan:
510         # Scan before building...
511         logging.info("Scanning source for common problems...")
512         count = common.scan_source(build_dir, root_dir, thisbuild)
513         if count > 0:
514             if force:
515                 logging.warn('Scanner found %d problems:' % count)
516             else:
517                 raise BuildException("Can't build due to %d errors while scanning" % count)
518
519     if not options.notarball:
520         # Build the source tarball right before we build the release...
521         logging.info("Creating source tarball...")
522         tarname = common.getsrcname(app, thisbuild)
523         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
524
525         def tarexc(f):
526             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
527         tarball.add(build_dir, tarname, exclude=tarexc)
528         tarball.close()
529
530     if onserver:
531         manifest = os.path.join(root_dir, 'AndroidManifest.xml')
532         if os.path.exists(manifest):
533             homedir = os.path.expanduser('~')
534             with open(os.path.join(homedir, 'buildserverid'), 'r') as f:
535                 buildserverid = f.read()
536             with open(os.path.join(homedir, 'fdroidserverid'), 'r') as f:
537                 fdroidserverid = f.read()
538             with open(manifest, 'r') as f:
539                 manifestcontent = f.read()
540             manifestcontent = manifestcontent.replace('</manifest>',
541                                                       '<fdroid buildserverid="'
542                                                       + buildserverid + '"'
543                                                       + ' fdroidserverid="'
544                                                       + fdroidserverid + '"'
545                                                       + '/></manifest>')
546             with open(manifest, 'w') as f:
547                 f.write(manifestcontent)
548
549     # Run a build command if one is required...
550     if thisbuild['build']:
551         logging.info("Running 'build' commands in %s" % root_dir)
552         cmd = common.replace_config_vars(thisbuild['build'])
553
554         # Substitute source library paths into commands...
555         for name, number, libpath in srclibpaths:
556             libpath = os.path.relpath(libpath, root_dir)
557             cmd = cmd.replace('$$' + name + '$$', libpath)
558
559         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
560
561         if p.returncode != 0:
562             raise BuildException("Error running build command for %s:%s" %
563                                  (app['id'], thisbuild['version']), p.stdout)
564
565     # Build native stuff if required...
566     if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
567         logging.info("Building the native code")
568         jni_components = thisbuild['buildjni']
569
570         if jni_components == ['yes']:
571             jni_components = ['']
572         cmd = [os.path.join(config['ndk_path'], "ndk-build"), "-j1"]
573         for d in jni_components:
574             if d:
575                 logging.info("Building native code in '%s'" % d)
576             else:
577                 logging.info("Building native code in the main project")
578             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
579             if os.path.exists(manifest):
580                 # Read and write the whole AM.xml to fix newlines and avoid
581                 # the ndk r8c or later 'wordlist' errors. The outcome of this
582                 # under gnu/linux is the same as when using tools like
583                 # dos2unix, but the native python way is faster and will
584                 # work in non-unix systems.
585                 manifest_text = open(manifest, 'U').read()
586                 open(manifest, 'w').write(manifest_text)
587                 # In case the AM.xml read was big, free the memory
588                 del manifest_text
589             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
590             if p.returncode != 0:
591                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
592
593     p = None
594     # Build the release...
595     if thisbuild['type'] == 'maven':
596         logging.info("Building Maven project...")
597
598         if '@' in thisbuild['maven']:
599             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
600         else:
601             maven_dir = root_dir
602
603         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
604                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
605                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
606                   'package']
607         if thisbuild['target']:
608             target = thisbuild["target"].split('-')[1]
609             FDroidPopen(['sed', '-i',
610                          's@<platform>[0-9]*</platform>@<platform>'
611                          + target + '</platform>@g',
612                          'pom.xml'],
613                         cwd=root_dir)
614             if '@' in thisbuild['maven']:
615                 FDroidPopen(['sed', '-i',
616                              's@<platform>[0-9]*</platform>@<platform>'
617                              + target + '</platform>@g',
618                              'pom.xml'],
619                             cwd=maven_dir)
620
621         p = FDroidPopen(mvncmd, cwd=maven_dir)
622
623         bindir = os.path.join(root_dir, 'target')
624
625     elif thisbuild['type'] == 'kivy':
626         logging.info("Building Kivy project...")
627
628         spec = os.path.join(root_dir, 'buildozer.spec')
629         if not os.path.exists(spec):
630             raise BuildException("Expected to find buildozer-compatible spec at {0}"
631                                  .format(spec))
632
633         defaults = {'orientation': 'landscape', 'icon': '',
634                     'permissions': '', 'android.api': "18"}
635         bconfig = ConfigParser(defaults, allow_no_value=True)
636         bconfig.read(spec)
637
638         distdir = 'python-for-android/dist/fdroid'
639         if os.path.exists(distdir):
640             shutil.rmtree(distdir)
641
642         modules = bconfig.get('app', 'requirements').split(',')
643
644         cmd = 'ANDROIDSDK=' + config['sdk_path']
645         cmd += ' ANDROIDNDK=' + config['ndk_path']
646         cmd += ' ANDROIDNDKVER=r9'
647         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
648         cmd += ' VIRTUALENV=virtualenv'
649         cmd += ' ./distribute.sh'
650         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
651         cmd += ' -d fdroid'
652         p = FDroidPopen(cmd, cwd='python-for-android', shell=True)
653         if p.returncode != 0:
654             raise BuildException("Distribute build failed")
655
656         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
657         if cid != app['id']:
658             raise BuildException("Package ID mismatch between metadata and spec")
659
660         orientation = bconfig.get('app', 'orientation', 'landscape')
661         if orientation == 'all':
662             orientation = 'sensor'
663
664         cmd = ['./build.py'
665                '--dir', root_dir,
666                '--name', bconfig.get('app', 'title'),
667                '--package', app['id'],
668                '--version', bconfig.get('app', 'version'),
669                '--orientation', orientation
670                ]
671
672         perms = bconfig.get('app', 'permissions')
673         for perm in perms.split(','):
674             cmd.extend(['--permission', perm])
675
676         if config.get('app', 'fullscreen') == 0:
677             cmd.append('--window')
678
679         icon = bconfig.get('app', 'icon.filename')
680         if icon:
681             cmd.extend(['--icon', os.path.join(root_dir, icon)])
682
683         cmd.append('release')
684         p = FDroidPopen(cmd, cwd=distdir)
685
686     elif thisbuild['type'] == 'gradle':
687         logging.info("Building Gradle project...")
688         if '@' in thisbuild['gradle']:
689             flavours = thisbuild['gradle'].split('@')[0].split(',')
690             gradle_dir = thisbuild['gradle'].split('@')[1]
691             gradle_dir = os.path.join(root_dir, gradle_dir)
692         else:
693             flavours = thisbuild['gradle'].split(',')
694             gradle_dir = root_dir
695
696         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
697             flavours[0] = ''
698
699         commands = [config['gradle']]
700         if thisbuild['preassemble']:
701             commands += thisbuild['preassemble'].split()
702
703         flavours_cmd = ''.join(flavours)
704         if flavours_cmd:
705             flavours_cmd = flavours_cmd[0].upper() + flavours_cmd[1:]
706
707         commands += ['assemble' + flavours_cmd + 'Release']
708
709         # Avoid having to use lintOptions.abortOnError false
710         # TODO: Do flavours or project names change this task name?
711         if thisbuild['gradlepluginver'] >= LooseVersion('0.8'):
712             commands += ['-x', 'lintVital' + flavours_cmd + 'Release']
713
714         p = FDroidPopen(commands, cwd=gradle_dir)
715
716     elif thisbuild['type'] == 'ant':
717         logging.info("Building Ant project...")
718         cmd = ['ant']
719         if thisbuild['antcommand']:
720             cmd += [thisbuild['antcommand']]
721         else:
722             cmd += ['release']
723         p = FDroidPopen(cmd, cwd=root_dir)
724
725         bindir = os.path.join(root_dir, 'bin')
726
727     if p is not None and p.returncode != 0:
728         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
729     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
730
731     if thisbuild['type'] == 'maven':
732         stdout_apk = '\n'.join([
733             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk', '.ap_'))])
734         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
735                      stdout_apk, re.S | re.M)
736         if not m:
737             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
738                          stdout_apk, re.S | re.M)
739         if not m:
740             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
741                          stdout_apk, re.S | re.M)
742         if not m:
743             raise BuildException('Failed to find output')
744         src = m.group(1)
745         src = os.path.join(bindir, src) + '.apk'
746     elif thisbuild['type'] == 'kivy':
747         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
748             bconfig.get('app', 'title'), bconfig.get('app', 'version'))
749     elif thisbuild['type'] == 'gradle':
750         basename = app['id']
751         dd = build_dir
752         if thisbuild['subdir']:
753             dd = os.path.join(dd, thisbuild['subdir'])
754             basename = os.path.basename(thisbuild['subdir'])
755         if '@' in thisbuild['gradle']:
756             dd = os.path.join(dd, thisbuild['gradle'].split('@')[1])
757             basename = app['id']
758         if len(flavours) == 1 and flavours[0] == '':
759             name = '-'.join([basename, 'release', 'unsigned'])
760         else:
761             name = '-'.join([basename, '-'.join(flavours), 'release', 'unsigned'])
762         dd = os.path.normpath(dd)
763         if thisbuild['gradlepluginver'] >= LooseVersion('0.11'):
764             src = os.path.join(dd, 'build', 'outputs', 'apk', name + '.apk')
765         else:
766             src = os.path.join(dd, 'build', 'apk', name + '.apk')
767     elif thisbuild['type'] == 'ant':
768         stdout_apk = '\n'.join([
769             line for line in p.stdout.splitlines() if '.apk' in line])
770         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
771                        re.S | re.M).group(1)
772         src = os.path.join(bindir, src)
773     elif thisbuild['type'] == 'raw':
774         src = os.path.join(root_dir, thisbuild['output'])
775         src = os.path.normpath(src)
776
777     # Make sure it's not debuggable...
778     if common.isApkDebuggable(src, config):
779         raise BuildException("APK is debuggable")
780
781     # By way of a sanity check, make sure the version and version
782     # code in our new apk match what we expect...
783     logging.info("Checking " + src)
784     if not os.path.exists(src):
785         raise BuildException("Unsigned apk is not at expected location of " + src)
786
787     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
788                                   config['build_tools'], 'aapt'),
789                      'dump', 'badging', src])
790
791     vercode = None
792     version = None
793     foundid = None
794     nativecode = None
795     for line in p.stdout.splitlines():
796         if line.startswith("package:"):
797             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
798             m = pat.match(line)
799             if m:
800                 foundid = m.group(1)
801             pat = re.compile(".*versionCode='([0-9]*)'.*")
802             m = pat.match(line)
803             if m:
804                 vercode = m.group(1)
805             pat = re.compile(".*versionName='([^']*)'.*")
806             m = pat.match(line)
807             if m:
808                 version = m.group(1)
809         elif line.startswith("native-code:"):
810             nativecode = line[12:]
811
812     if thisbuild['buildjni']:
813         if nativecode is None or "'" not in nativecode:
814             raise BuildException("Native code should have been built but none was packaged")
815     if thisbuild['novcheck']:
816         vercode = thisbuild['vercode']
817         version = thisbuild['version']
818     if not version or not vercode:
819         raise BuildException("Could not find version information in build in output")
820     if not foundid:
821         raise BuildException("Could not find package ID in output")
822     if foundid != app['id']:
823         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
824
825     # Some apps (e.g. Timeriffic) have had the bonkers idea of
826     # including the entire changelog in the version number. Remove
827     # it so we can compare. (TODO: might be better to remove it
828     # before we compile, in fact)
829     index = version.find(" //")
830     if index != -1:
831         version = version[:index]
832
833     if (version != thisbuild['version'] or
834             vercode != thisbuild['vercode']):
835         raise BuildException(("Unexpected version/version code in output;"
836                               " APK: '%s' / '%s', "
837                               " Expected: '%s' / '%s'")
838                              % (version, str(vercode), thisbuild['version'],
839                                 str(thisbuild['vercode']))
840                              )
841
842     # Copy the unsigned apk to our destination directory for further
843     # processing (by publish.py)...
844     dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
845     shutil.copyfile(src, dest)
846
847     # Move the source tarball into the output directory...
848     if output_dir != tmp_dir and not options.notarball:
849         shutil.move(os.path.join(tmp_dir, tarname),
850                     os.path.join(output_dir, tarname))
851
852
853 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
854              tmp_dir, repo_dir, vcs, test, server, force, onserver):
855     """
856     Build a particular version of an application, if it needs building.
857
858     :param output_dir: The directory where the build output will go. Usually
859        this is the 'unsigned' directory.
860     :param repo_dir: The repo directory - used for checking if the build is
861        necessary.
862     :paaram also_check_dir: An additional location for checking if the build
863        is necessary (usually the archive repo)
864     :param test: True if building in test mode, in which case the build will
865        always happen, even if the output already exists. In test mode, the
866        output directory should be a temporary location, not any of the real
867        ones.
868
869     :returns: True if the build was done, False if it wasn't necessary.
870     """
871
872     dest_apk = common.getapkname(app, thisbuild)
873
874     dest = os.path.join(output_dir, dest_apk)
875     dest_repo = os.path.join(repo_dir, dest_apk)
876
877     if not test:
878         if os.path.exists(dest) or os.path.exists(dest_repo):
879             return False
880
881         if also_check_dir:
882             dest_also = os.path.join(also_check_dir, dest_apk)
883             if os.path.exists(dest_also):
884                 return False
885
886     if thisbuild['disable']:
887         return False
888
889     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
890
891     if server:
892         # When using server mode, still keep a local cache of the repo, by
893         # grabbing the source now.
894         vcs.gotorevision(thisbuild['commit'])
895
896         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
897     else:
898         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
899     return True
900
901
902 def parse_commandline():
903     """Parse the command line. Returns options, args."""
904
905     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
906     parser.add_option("-v", "--verbose", action="store_true", default=False,
907                       help="Spew out even more information than normal")
908     parser.add_option("-q", "--quiet", action="store_true", default=False,
909                       help="Restrict output to warnings and errors")
910     parser.add_option("-l", "--latest", action="store_true", default=False,
911                       help="Build only the latest version of each package")
912     parser.add_option("-s", "--stop", action="store_true", default=False,
913                       help="Make the build stop on exceptions")
914     parser.add_option("-t", "--test", action="store_true", default=False,
915                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
916     parser.add_option("--server", action="store_true", default=False,
917                       help="Use build server")
918     parser.add_option("--resetserver", action="store_true", default=False,
919                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
920     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
921                       help="Specify that we're running on the build server")
922     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
923                       help="Skip scanning the source code for binaries and other problems")
924     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
925                       help="Don't create a source tarball, useful when testing a build")
926     parser.add_option("-f", "--force", action="store_true", default=False,
927                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
928     parser.add_option("-a", "--all", action="store_true", default=False,
929                       help="Build all applications available")
930     parser.add_option("-w", "--wiki", default=False, action="store_true",
931                       help="Update the wiki")
932     options, args = parser.parse_args()
933
934     # Force --stop with --on-server to get correct exit code
935     if options.onserver:
936         options.stop = True
937
938     if options.force and not options.test:
939         raise OptionError("Force is only allowed in test mode", "force")
940
941     return options, args
942
943 options = None
944 config = None
945
946
947 def main():
948
949     global options, config
950
951     options, args = parse_commandline()
952     if not args and not options.all:
953         raise OptionError("If you really want to build all the apps, use --all", "all")
954
955     config = common.read_config(options)
956
957     if config['build_server_always']:
958         options.server = True
959     if options.resetserver and not options.server:
960         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
961
962     log_dir = 'logs'
963     if not os.path.isdir(log_dir):
964         logging.info("Creating log directory")
965         os.makedirs(log_dir)
966
967     tmp_dir = 'tmp'
968     if not os.path.isdir(tmp_dir):
969         logging.info("Creating temporary directory")
970         os.makedirs(tmp_dir)
971
972     if options.test:
973         output_dir = tmp_dir
974     else:
975         output_dir = 'unsigned'
976         if not os.path.isdir(output_dir):
977             logging.info("Creating output directory")
978             os.makedirs(output_dir)
979
980     if config['archive_older'] != 0:
981         also_check_dir = 'archive'
982     else:
983         also_check_dir = None
984
985     repo_dir = 'repo'
986
987     build_dir = 'build'
988     if not os.path.isdir(build_dir):
989         logging.info("Creating build directory")
990         os.makedirs(build_dir)
991     srclib_dir = os.path.join(build_dir, 'srclib')
992     extlib_dir = os.path.join(build_dir, 'extlib')
993
994     # Read all app and srclib metadata
995     allapps = metadata.read_metadata(xref=not options.onserver)
996     metadata.read_srclibs()
997
998     apps = common.read_app_args(args, allapps, True)
999     apps = [app for app in apps if (options.force or not app['Disabled']) and
1000             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
1001
1002     if len(apps) == 0:
1003         raise Exception("No apps to process.")
1004
1005     if options.latest:
1006         for app in apps:
1007             for build in reversed(app['builds']):
1008                 if build['disable']:
1009                     continue
1010                 app['builds'] = [build]
1011                 break
1012
1013     if options.wiki:
1014         import mwclient
1015         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1016                              path=config['wiki_path'])
1017         site.login(config['wiki_user'], config['wiki_password'])
1018
1019     # Build applications...
1020     failed_apps = {}
1021     build_succeeded = []
1022     for app in apps:
1023
1024         first = True
1025
1026         for thisbuild in app['builds']:
1027             wikilog = None
1028             try:
1029
1030                 # For the first build of a particular app, we need to set up
1031                 # the source repo. We can reuse it on subsequent builds, if
1032                 # there are any.
1033                 if first:
1034                     if app['Repo Type'] == 'srclib':
1035                         build_dir = os.path.join('build', 'srclib', app['Repo'])
1036                     else:
1037                         build_dir = os.path.join('build', app['id'])
1038
1039                     # Set up vcs interface and make sure we have the latest code...
1040                     logging.debug("Getting {0} vcs interface for {1}"
1041                                   .format(app['Repo Type'], app['Repo']))
1042                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
1043
1044                     first = False
1045
1046                 logging.debug("Checking " + thisbuild['version'])
1047                 if trybuild(app, thisbuild, build_dir, output_dir,
1048                             also_check_dir, srclib_dir, extlib_dir,
1049                             tmp_dir, repo_dir, vcs, options.test,
1050                             options.server, options.force,
1051                             options.onserver):
1052                     build_succeeded.append(app)
1053                     wikilog = "Build succeeded"
1054             except BuildException as be:
1055                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
1056                 logfile.write(str(be))
1057                 logfile.close()
1058                 reason = str(be).split('\n', 1)[0] if options.verbose else str(be)
1059                 print("Could not build app %s due to BuildException: %s" % (
1060                     app['id'], reason))
1061                 if options.stop:
1062                     sys.exit(1)
1063                 failed_apps[app['id']] = be
1064                 wikilog = be.get_wikitext()
1065             except VCSException as vcse:
1066                 print("VCS error while building app %s: %s" % (app['id'], vcse))
1067                 if options.stop:
1068                     sys.exit(1)
1069                 failed_apps[app['id']] = vcse
1070                 wikilog = str(vcse)
1071             except Exception as e:
1072                 print("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
1073                 if options.stop:
1074                     sys.exit(1)
1075                 failed_apps[app['id']] = e
1076                 wikilog = str(e)
1077
1078             if options.wiki and wikilog:
1079                 try:
1080                     newpage = site.Pages[app['id'] + '/lastbuild']
1081                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1082                     newpage.save(txt, summary='Build log')
1083                 except:
1084                     logging.info("Error while attempting to publish build log")
1085
1086     for app in build_succeeded:
1087         logging.info("success: %s" % (app['id']))
1088
1089     if not options.verbose:
1090         for fa in failed_apps:
1091             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1092
1093     logging.info("Finished.")
1094     if len(build_succeeded) > 0:
1095         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1096     if len(failed_apps) > 0:
1097         logging.info(str(len(failed_apps)) + ' builds failed')
1098
1099     sys.exit(0)
1100
1101 if __name__ == "__main__":
1102     main()