chiark / gitweb /
3123a4ae9d835998113602b389c2a5409e6692bb
[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', 'release'),
808                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
809                 os.path.join(root_dir, 'build', 'apk'),
810                 ]:
811             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
812                 apks = glob.glob(os.path.join(apks_dir, apkglob))
813
814                 if len(apks) > 1:
815                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
816                                          '\n'.join(apks))
817                 if len(apks) == 1:
818                     src = apks[0]
819                     break
820             if src is not None:
821                 break
822
823         if src is None:
824             raise BuildException('Failed to find any output apks')
825
826     elif omethod == 'ant':
827         stdout_apk = '\n'.join([
828             line for line in p.output.splitlines() if '.apk' in line])
829         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
830                        re.S | re.M).group(1)
831         src = os.path.join(bindir, src)
832     elif omethod == 'raw':
833         output_path = common.replace_build_vars(build.output, build)
834         globpath = os.path.join(root_dir, output_path)
835         apks = glob.glob(globpath)
836         if len(apks) > 1:
837             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
838         if len(apks) < 1:
839             raise BuildException('No apks match %s' % globpath)
840         src = os.path.normpath(apks[0])
841
842     # Make sure it's not debuggable...
843     if common.isApkAndDebuggable(src):
844         raise BuildException("APK is debuggable")
845
846     # By way of a sanity check, make sure the version and version
847     # code in our new apk match what we expect...
848     logging.debug("Checking " + src)
849     if not os.path.exists(src):
850         raise BuildException("Unsigned apk is not at expected location of " + src)
851
852     if common.get_file_extension(src) == 'apk':
853         vercode, version = get_metadata_from_apk(app, build, src)
854         if (version != build.versionName or vercode != build.versionCode):
855             raise BuildException(("Unexpected version/version code in output;"
856                                   " APK: '%s' / '%s', "
857                                   " Expected: '%s' / '%s'")
858                                  % (version, str(vercode), build.versionName,
859                                     str(build.versionCode)))
860     else:
861         vercode = build.versionCode
862         version = build.versionName
863
864     # Add information for 'fdroid verify' to be able to reproduce the build
865     # environment.
866     if onserver:
867         metadir = os.path.join(tmp_dir, 'META-INF')
868         if not os.path.exists(metadir):
869             os.mkdir(metadir)
870         homedir = os.path.expanduser('~')
871         for fn in ['buildserverid', 'fdroidserverid']:
872             shutil.copyfile(os.path.join(homedir, fn),
873                             os.path.join(metadir, fn))
874             subprocess.call(['jar', 'uf', os.path.abspath(src),
875                              'META-INF/' + fn], cwd=tmp_dir)
876
877     # Copy the unsigned apk to our destination directory for further
878     # processing (by publish.py)...
879     dest = os.path.join(output_dir, common.get_release_filename(app, build))
880     shutil.copyfile(src, dest)
881
882     # Move the source tarball into the output directory...
883     if output_dir != tmp_dir and not options.notarball:
884         shutil.move(os.path.join(tmp_dir, tarname),
885                     os.path.join(output_dir, tarname))
886
887
888 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
889              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
890              server, force, onserver, refresh):
891     """
892     Build a particular version of an application, if it needs building.
893
894     :param output_dir: The directory where the build output will go. Usually
895        this is the 'unsigned' directory.
896     :param repo_dir: The repo directory - used for checking if the build is
897        necessary.
898     :param also_check_dir: An additional location for checking if the build
899        is necessary (usually the archive repo)
900     :param test: True if building in test mode, in which case the build will
901        always happen, even if the output already exists. In test mode, the
902        output directory should be a temporary location, not any of the real
903        ones.
904
905     :returns: True if the build was done, False if it wasn't necessary.
906     """
907
908     dest_file = common.get_release_filename(app, build)
909
910     dest = os.path.join(output_dir, dest_file)
911     dest_repo = os.path.join(repo_dir, dest_file)
912
913     if not test:
914         if os.path.exists(dest) or os.path.exists(dest_repo):
915             return False
916
917         if also_check_dir:
918             dest_also = os.path.join(also_check_dir, dest_file)
919             if os.path.exists(dest_also):
920                 return False
921
922     if build.disable and not options.force:
923         return False
924
925     logging.info("Building version %s (%s) of %s" % (
926         build.versionName, build.versionCode, app.id))
927
928     if server:
929         # When using server mode, still keep a local cache of the repo, by
930         # grabbing the source now.
931         vcs.gotorevision(build.commit)
932
933         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
934     else:
935         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
936     return True
937
938
939 def get_android_tools_versions(ndk_path=None):
940     '''get a list of the versions of all installed Android SDK/NDK components'''
941
942     global config
943     sdk_path = config['sdk_path']
944     if sdk_path[-1] != '/':
945         sdk_path += '/'
946     components = []
947     if ndk_path:
948         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
949         if os.path.isfile(ndk_release_txt):
950             with open(ndk_release_txt, 'r') as fp:
951                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
952
953     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
954     for root, dirs, files in os.walk(sdk_path):
955         if 'source.properties' in files:
956             source_properties = os.path.join(root, 'source.properties')
957             with open(source_properties, 'r') as fp:
958                 m = pattern.search(fp.read())
959                 if m:
960                     components.append((root[len(sdk_path):], m.group(1)))
961
962     return components
963
964
965 def get_android_tools_version_log(ndk_path):
966     '''get a list of the versions of all installed Android SDK/NDK components'''
967     log = '== Installed Android Tools ==\n\n'
968     components = get_android_tools_versions(ndk_path)
969     for name, version in sorted(components):
970         log += '* ' + name + ' (' + version + ')\n'
971
972     return log
973
974
975 def parse_commandline():
976     """Parse the command line. Returns options, parser."""
977
978     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
979     common.setup_global_opts(parser)
980     parser.add_argument("appid", nargs='*', help="app-id with optional versionCode in the form APPID[:VERCODE]")
981     parser.add_argument("-l", "--latest", action="store_true", default=False,
982                         help="Build only the latest version of each package")
983     parser.add_argument("-s", "--stop", action="store_true", default=False,
984                         help="Make the build stop on exceptions")
985     parser.add_argument("-t", "--test", action="store_true", default=False,
986                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
987     parser.add_argument("--server", action="store_true", default=False,
988                         help="Use build server")
989     parser.add_argument("--resetserver", action="store_true", default=False,
990                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
991     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
992                         help="Specify that we're running on the build server")
993     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
994                         help="Skip scanning the source code for binaries and other problems")
995     parser.add_argument("--dscanner", action="store_true", default=False,
996                         help="Setup an emulator, install the apk on it and perform a drozer scan")
997     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
998                         help="Don't create a source tarball, useful when testing a build")
999     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1000                         help="Don't refresh the repository, useful when testing a build with no internet connection")
1001     parser.add_argument("-f", "--force", action="store_true", default=False,
1002                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
1003     parser.add_argument("-a", "--all", action="store_true", default=False,
1004                         help="Build all applications available")
1005     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1006                         help="Update the wiki")
1007     metadata.add_metadata_arguments(parser)
1008     options = parser.parse_args()
1009     metadata.warnings_action = options.W
1010
1011     # Force --stop with --on-server to get correct exit code
1012     if options.onserver:
1013         options.stop = True
1014
1015     if options.force and not options.test:
1016         parser.error("option %s: Force is only allowed in test mode" % "force")
1017
1018     return options, parser
1019
1020
1021 options = None
1022 config = None
1023 buildserverid = None
1024
1025
1026 def main():
1027
1028     global options, config, buildserverid
1029
1030     options, parser = parse_commandline()
1031
1032     # The defaults for .fdroid.* metadata that is included in a git repo are
1033     # different than for the standard metadata/ layout because expectations
1034     # are different.  In this case, the most common user will be the app
1035     # developer working on the latest update of the app on their own machine.
1036     local_metadata_files = common.get_local_metadata_files()
1037     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1038         config = dict(common.default_config)
1039         # `fdroid build` should build only the latest version by default since
1040         # most of the time the user will be building the most recent update
1041         if not options.all:
1042             options.latest = True
1043     elif len(local_metadata_files) > 1:
1044         raise FDroidException("Only one local metadata file allowed! Found: "
1045                               + " ".join(local_metadata_files))
1046     else:
1047         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1048             raise FDroidException("No app metadata found, nothing to process!")
1049         if not options.appid and not options.all:
1050             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1051
1052     config = common.read_config(options)
1053
1054     if config['build_server_always']:
1055         options.server = True
1056     if options.resetserver and not options.server:
1057         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1058
1059     log_dir = 'logs'
1060     if not os.path.isdir(log_dir):
1061         logging.info("Creating log directory")
1062         os.makedirs(log_dir)
1063
1064     tmp_dir = 'tmp'
1065     if not os.path.isdir(tmp_dir):
1066         logging.info("Creating temporary directory")
1067         os.makedirs(tmp_dir)
1068
1069     if options.test:
1070         output_dir = tmp_dir
1071     else:
1072         output_dir = 'unsigned'
1073         if not os.path.isdir(output_dir):
1074             logging.info("Creating output directory")
1075             os.makedirs(output_dir)
1076
1077     if config['archive_older'] != 0:
1078         also_check_dir = 'archive'
1079     else:
1080         also_check_dir = None
1081
1082     repo_dir = 'repo'
1083
1084     build_dir = 'build'
1085     if not os.path.isdir(build_dir):
1086         logging.info("Creating build directory")
1087         os.makedirs(build_dir)
1088     srclib_dir = os.path.join(build_dir, 'srclib')
1089     extlib_dir = os.path.join(build_dir, 'extlib')
1090
1091     # Read all app and srclib metadata
1092     pkgs = common.read_pkg_args(options.appid, True)
1093     allapps = metadata.read_metadata(not options.onserver, pkgs)
1094     apps = common.read_app_args(options.appid, allapps, True)
1095
1096     for appid, app in list(apps.items()):
1097         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1098             del apps[appid]
1099
1100     if not apps:
1101         raise FDroidException("No apps to process.")
1102
1103     if options.latest:
1104         for app in apps.values():
1105             for build in reversed(app.builds):
1106                 if build.disable and not options.force:
1107                     continue
1108                 app.builds = [build]
1109                 break
1110
1111     if options.wiki:
1112         import mwclient
1113         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1114                              path=config['wiki_path'])
1115         site.login(config['wiki_user'], config['wiki_password'])
1116
1117     # Build applications...
1118     failed_apps = {}
1119     build_succeeded = []
1120     for appid, app in apps.items():
1121
1122         first = True
1123
1124         for build in app.builds:
1125             wikilog = None
1126             tools_version_log = ''
1127             if not options.onserver:
1128                 tools_version_log = get_android_tools_version_log(build.ndk_path())
1129             try:
1130
1131                 # For the first build of a particular app, we need to set up
1132                 # the source repo. We can reuse it on subsequent builds, if
1133                 # there are any.
1134                 if first:
1135                     vcs, build_dir = common.setup_vcs(app)
1136                     first = False
1137
1138                 logging.debug("Checking " + build.versionName)
1139                 if trybuild(app, build, build_dir, output_dir, log_dir,
1140                             also_check_dir, srclib_dir, extlib_dir,
1141                             tmp_dir, repo_dir, vcs, options.test,
1142                             options.server, options.force,
1143                             options.onserver, options.refresh):
1144                     toolslog = os.path.join(log_dir,
1145                                             common.get_toolsversion_logname(app, build))
1146                     if not options.onserver and os.path.exists(toolslog):
1147                         with open(toolslog, 'r') as f:
1148                             tools_version_log = ''.join(f.readlines())
1149                         os.remove(toolslog)
1150
1151                     if app.Binaries is not None:
1152                         # This is an app where we build from source, and
1153                         # verify the apk contents against a developer's
1154                         # binary. We get that binary now, and save it
1155                         # alongside our built one in the 'unsigend'
1156                         # directory.
1157                         url = app.Binaries
1158                         url = url.replace('%v', build.versionName)
1159                         url = url.replace('%c', str(build.versionCode))
1160                         logging.info("...retrieving " + url)
1161                         of = re.sub(r'.apk$', '.binary.apk', common.get_release_filename(app, build))
1162                         of = os.path.join(output_dir, of)
1163                         try:
1164                             net.download_file(url, local_filename=of)
1165                         except requests.exceptions.HTTPError as e:
1166                             raise FDroidException(
1167                                 'Downloading Binaries from %s failed. %s' % (url, e))
1168
1169                         # Now we check weather the build can be verified to
1170                         # match the supplied binary or not. Should the
1171                         # comparison fail, we mark this build as a failure
1172                         # and remove everything from the unsigend folder.
1173                         with tempfile.TemporaryDirectory() as tmpdir:
1174                             unsigned_apk = \
1175                                 common.get_release_filename(app, build)
1176                             unsigned_apk = \
1177                                 os.path.join(output_dir, unsigned_apk)
1178                             compare_result = \
1179                                 common.verify_apks(of, unsigned_apk, tmpdir)
1180                             if compare_result:
1181                                 logging.debug('removing %s', unsigned_apk)
1182                                 os.remove(unsigned_apk)
1183                                 logging.debug('removing %s', of)
1184                                 os.remove(of)
1185                                 compare_result = compare_result.split('\n')
1186                                 line_count = len(compare_result)
1187                                 compare_result = compare_result[:299]
1188                                 if line_count > len(compare_result):
1189                                     line_difference = \
1190                                         line_count - len(compare_result)
1191                                     compare_result.append('%d more lines ...' %
1192                                                           line_difference)
1193                                 compare_result = '\n'.join(compare_result)
1194                                 raise FDroidException('compared built binary '
1195                                                       'to supplied reference '
1196                                                       'binary but failed',
1197                                                       compare_result)
1198                             else:
1199                                 logging.info('compared built binary to '
1200                                              'supplied reference binary '
1201                                              'successfully')
1202
1203                     build_succeeded.append(app)
1204                     wikilog = "Build succeeded"
1205
1206             except VCSException as vcse:
1207                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1208                 logging.error("VCS error while building app %s: %s" % (
1209                     appid, reason))
1210                 if options.stop:
1211                     sys.exit(1)
1212                 failed_apps[appid] = vcse
1213                 wikilog = str(vcse)
1214             except FDroidException as e:
1215                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1216                     f.write('\n\n============================================================\n')
1217                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1218                             (build.versionCode, build.versionName, build.commit))
1219                     f.write('Build completed at '
1220                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1221                     f.write('\n' + tools_version_log + '\n')
1222                     f.write(str(e))
1223                 logging.error("Could not build app %s: %s" % (appid, e))
1224                 if options.stop:
1225                     sys.exit(1)
1226                 failed_apps[appid] = e
1227                 wikilog = e.get_wikitext()
1228             except Exception as e:
1229                 logging.error("Could not build app %s due to unknown error: %s" % (
1230                     appid, traceback.format_exc()))
1231                 if options.stop:
1232                     sys.exit(1)
1233                 failed_apps[appid] = e
1234                 wikilog = str(e)
1235
1236             if options.wiki and wikilog:
1237                 try:
1238                     # Write a page with the last build log for this version code
1239                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1240                     newpage = site.Pages[lastbuildpage]
1241                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1242                         fdroidserverid = fp.read().rstrip()
1243                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1244                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1245                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1246                     if options.onserver:
1247                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1248                                + buildserverid + ' ' + buildserverid + ']\n\n'
1249                     txt += tools_version_log + '\n\n'
1250                     txt += '== Build Log ==\n\n' + wikilog
1251                     newpage.save(txt, summary='Build log')
1252                     # Redirect from /lastbuild to the most recent build log
1253                     newpage = site.Pages[appid + '/lastbuild']
1254                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1255                 except Exception as e:
1256                     logging.error("Error while attempting to publish build log: %s" % e)
1257
1258     for app in build_succeeded:
1259         logging.info("success: %s" % (app.id))
1260
1261     if not options.verbose:
1262         for fa in failed_apps:
1263             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1264
1265     # perform a drozer scan of all successful builds
1266     if options.dscanner and build_succeeded:
1267         from .dscanner import DockerDriver
1268
1269         docker = DockerDriver()
1270
1271         try:
1272             for app in build_succeeded:
1273
1274                 logging.info("Need to sign the app before we can install it.")
1275                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1276
1277                 apk_path = None
1278
1279                 for f in os.listdir(repo_dir):
1280                     if f.endswith('.apk') and f.startswith(app.id):
1281                         apk_path = os.path.join(repo_dir, f)
1282                         break
1283
1284                 if not apk_path:
1285                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1286
1287                 if not os.path.isdir(repo_dir):
1288                     exit(1)
1289
1290                 logging.info("Performing Drozer scan on {0}.".format(app))
1291                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1292         except Exception as e:
1293             logging.error(str(e))
1294             logging.error("An exception happened. Making sure to clean up")
1295         else:
1296             logging.info("Scan succeeded.")
1297
1298         logging.info("Cleaning up after ourselves.")
1299         docker.clean()
1300
1301     logging.info("Finished.")
1302     if len(build_succeeded) > 0:
1303         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1304     if len(failed_apps) > 0:
1305         logging.info(str(len(failed_apps)) + ' builds failed')
1306
1307     sys.exit(0)
1308
1309
1310 if __name__ == "__main__":
1311     main()