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