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