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