chiark / gitweb /
Merge branch 'python-vagrant-copy-caches' 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(os.path.join('metadata', app.id + '.txt'),
140                 app.id + '.txt')
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         log_path = os.path.join(log_dir,
397                                 common.get_toolsversion_logname(app, build))
398         with open(log_path, 'w') as f:
399             f.write(get_android_tools_version_log(build.ndk_path()))
400
401     # Prepare the source code...
402     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
403                                                   build_dir, srclib_dir,
404                                                   extlib_dir, onserver, refresh)
405
406     # We need to clean via the build tool in case the binary dirs are
407     # different from the default ones
408     p = None
409     gradletasks = []
410     bmethod = build.build_method()
411     if bmethod == 'maven':
412         logging.info("Cleaning Maven project...")
413         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
414
415         if '@' in build.maven:
416             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
417             maven_dir = os.path.normpath(maven_dir)
418         else:
419             maven_dir = root_dir
420
421         p = FDroidPopen(cmd, cwd=maven_dir)
422
423     elif bmethod == 'gradle':
424
425         logging.info("Cleaning Gradle project...")
426
427         if build.preassemble:
428             gradletasks += build.preassemble
429
430         flavours = build.gradle
431         if flavours == ['yes']:
432             flavours = []
433
434         flavours_cmd = ''.join([capitalize_intact(flav) for flav in flavours])
435
436         gradletasks += ['assemble' + flavours_cmd + 'Release']
437
438         if config['force_build_tools']:
439             force_gradle_build_tools(build_dir, config['build_tools'])
440             for name, number, libpath in srclibpaths:
441                 force_gradle_build_tools(libpath, config['build_tools'])
442
443         cmd = [config['gradle']]
444         if build.gradleprops:
445             cmd += ['-P' + kv for kv in build.gradleprops]
446
447         cmd += ['clean']
448
449         p = FDroidPopen(cmd, cwd=root_dir)
450
451     elif bmethod == 'kivy':
452         pass
453
454     elif bmethod == 'ant':
455         logging.info("Cleaning Ant project...")
456         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
457
458     if p is not None and p.returncode != 0:
459         raise BuildException("Error cleaning %s:%s" %
460                              (app.id, build.versionName), p.output)
461
462     for root, dirs, files in os.walk(build_dir):
463
464         def del_dirs(dl):
465             for d in dl:
466                 if d in dirs:
467                     shutil.rmtree(os.path.join(root, d))
468
469         def del_files(fl):
470             for f in fl:
471                 if f in files:
472                     os.remove(os.path.join(root, f))
473
474         if 'build.gradle' in files:
475             # Even when running clean, gradle stores task/artifact caches in
476             # .gradle/ as binary files. To avoid overcomplicating the scanner,
477             # manually delete them, just like `gradle clean` should have removed
478             # the build/ dirs.
479             del_dirs(['build', '.gradle'])
480             del_files(['gradlew', 'gradlew.bat'])
481
482         if 'pom.xml' in files:
483             del_dirs(['target'])
484
485         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
486             del_dirs(['bin', 'gen'])
487
488         if 'jni' in dirs:
489             del_dirs(['obj'])
490
491     if options.skipscan:
492         if build.scandelete:
493             raise BuildException("Refusing to skip source scan since scandelete is present")
494     else:
495         # Scan before building...
496         logging.info("Scanning source for common problems...")
497         count = scanner.scan_source(build_dir, root_dir, build)
498         if count > 0:
499             if force:
500                 logging.warn('Scanner found %d problems' % count)
501             else:
502                 raise BuildException("Can't build due to %d errors while scanning" % count)
503
504     if not options.notarball:
505         # Build the source tarball right before we build the release...
506         logging.info("Creating source tarball...")
507         tarname = common.getsrcname(app, build)
508         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
509
510         def tarexc(f):
511             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
512         tarball.add(build_dir, tarname, exclude=tarexc)
513         tarball.close()
514
515     # Run a build command if one is required...
516     if build.build:
517         logging.info("Running 'build' commands in %s" % root_dir)
518         cmd = common.replace_config_vars(build.build, build)
519
520         # Substitute source library paths into commands...
521         for name, number, libpath in srclibpaths:
522             libpath = os.path.relpath(libpath, root_dir)
523             cmd = cmd.replace('$$' + name + '$$', libpath)
524
525         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
526
527         if p.returncode != 0:
528             raise BuildException("Error running build command for %s:%s" %
529                                  (app.id, build.versionName), p.output)
530
531     # Build native stuff if required...
532     if build.buildjni and build.buildjni != ['no']:
533         logging.info("Building the native code")
534         jni_components = build.buildjni
535
536         if jni_components == ['yes']:
537             jni_components = ['']
538         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
539         for d in jni_components:
540             if d:
541                 logging.info("Building native code in '%s'" % d)
542             else:
543                 logging.info("Building native code in the main project")
544             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
545             if os.path.exists(manifest):
546                 # Read and write the whole AM.xml to fix newlines and avoid
547                 # the ndk r8c or later 'wordlist' errors. The outcome of this
548                 # under gnu/linux is the same as when using tools like
549                 # dos2unix, but the native python way is faster and will
550                 # work in non-unix systems.
551                 manifest_text = open(manifest, 'U').read()
552                 open(manifest, 'w').write(manifest_text)
553                 # In case the AM.xml read was big, free the memory
554                 del manifest_text
555             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
556             if p.returncode != 0:
557                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
558
559     p = None
560     # Build the release...
561     if bmethod == 'maven':
562         logging.info("Building Maven project...")
563
564         if '@' in build.maven:
565             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
566         else:
567             maven_dir = root_dir
568
569         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
570                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
571                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
572                   'package']
573         if build.target:
574             target = build.target.split('-')[1]
575             common.regsub_file(r'<platform>[0-9]*</platform>',
576                                r'<platform>%s</platform>' % target,
577                                os.path.join(root_dir, 'pom.xml'))
578             if '@' in build.maven:
579                 common.regsub_file(r'<platform>[0-9]*</platform>',
580                                    r'<platform>%s</platform>' % target,
581                                    os.path.join(maven_dir, 'pom.xml'))
582
583         p = FDroidPopen(mvncmd, cwd=maven_dir)
584
585         bindir = os.path.join(root_dir, 'target')
586
587     elif bmethod == 'kivy':
588         logging.info("Building Kivy project...")
589
590         spec = os.path.join(root_dir, 'buildozer.spec')
591         if not os.path.exists(spec):
592             raise BuildException("Expected to find buildozer-compatible spec at {0}"
593                                  .format(spec))
594
595         defaults = {'orientation': 'landscape', 'icon': '',
596                     'permissions': '', 'android.api': "18"}
597         bconfig = ConfigParser(defaults, allow_no_value=True)
598         bconfig.read(spec)
599
600         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
601         if os.path.exists(distdir):
602             shutil.rmtree(distdir)
603
604         modules = bconfig.get('app', 'requirements').split(',')
605
606         cmd = 'ANDROIDSDK=' + config['sdk_path']
607         cmd += ' ANDROIDNDK=' + ndk_path
608         cmd += ' ANDROIDNDKVER=' + build.ndk
609         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
610         cmd += ' VIRTUALENV=virtualenv'
611         cmd += ' ./distribute.sh'
612         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
613         cmd += ' -d fdroid'
614         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
615         if p.returncode != 0:
616             raise BuildException("Distribute build failed")
617
618         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
619         if cid != app.id:
620             raise BuildException("Package ID mismatch between metadata and spec")
621
622         orientation = bconfig.get('app', 'orientation', 'landscape')
623         if orientation == 'all':
624             orientation = 'sensor'
625
626         cmd = ['./build.py'
627                '--dir', root_dir,
628                '--name', bconfig.get('app', 'title'),
629                '--package', app.id,
630                '--version', bconfig.get('app', 'version'),
631                '--orientation', orientation
632                ]
633
634         perms = bconfig.get('app', 'permissions')
635         for perm in perms.split(','):
636             cmd.extend(['--permission', perm])
637
638         if config.get('app', 'fullscreen') == 0:
639             cmd.append('--window')
640
641         icon = bconfig.get('app', 'icon.filename')
642         if icon:
643             cmd.extend(['--icon', os.path.join(root_dir, icon)])
644
645         cmd.append('release')
646         p = FDroidPopen(cmd, cwd=distdir)
647
648     elif bmethod == 'gradle':
649         logging.info("Building Gradle project...")
650
651         cmd = [config['gradle']]
652         if build.gradleprops:
653             cmd += ['-P' + kv for kv in build.gradleprops]
654
655         cmd += gradletasks
656
657         p = FDroidPopen(cmd, cwd=root_dir)
658
659     elif bmethod == 'ant':
660         logging.info("Building Ant project...")
661         cmd = ['ant']
662         if build.antcommands:
663             cmd += build.antcommands
664         else:
665             cmd += ['release']
666         p = FDroidPopen(cmd, cwd=root_dir)
667
668         bindir = os.path.join(root_dir, 'bin')
669
670     if p is not None and p.returncode != 0:
671         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
672     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
673
674     omethod = build.output_method()
675     if omethod == 'maven':
676         stdout_apk = '\n'.join([
677             line for line in p.output.splitlines() if any(
678                 a in line for a in ('.apk', '.ap_', '.jar'))])
679         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
680                      stdout_apk, re.S | re.M)
681         if not m:
682             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
683                          stdout_apk, re.S | re.M)
684         if not m:
685             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
686                          stdout_apk, re.S | re.M)
687
688         if not m:
689             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
690                          stdout_apk, re.S | re.M)
691         if not m:
692             raise BuildException('Failed to find output')
693         src = m.group(1)
694         src = os.path.join(bindir, src) + '.apk'
695     elif omethod == 'kivy':
696         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
697                            '{0}-{1}-release.apk'.format(
698                                bconfig.get('app', 'title'),
699                                bconfig.get('app', 'version')))
700     elif omethod == 'gradle':
701         src = None
702         for apks_dir in [
703                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
704                 os.path.join(root_dir, 'build', 'apk'),
705                 ]:
706             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
707                 apks = glob.glob(os.path.join(apks_dir, apkglob))
708
709                 if len(apks) > 1:
710                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
711                                          '\n'.join(apks))
712                 if len(apks) == 1:
713                     src = apks[0]
714                     break
715             if src is not None:
716                 break
717
718         if src is None:
719             raise BuildException('Failed to find any output apks')
720
721     elif omethod == 'ant':
722         stdout_apk = '\n'.join([
723             line for line in p.output.splitlines() if '.apk' in line])
724         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
725                        re.S | re.M).group(1)
726         src = os.path.join(bindir, src)
727     elif omethod == 'raw':
728         output_path = common.replace_build_vars(build.output, build)
729         globpath = os.path.join(root_dir, output_path)
730         apks = glob.glob(globpath)
731         if len(apks) > 1:
732             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
733         if len(apks) < 1:
734             raise BuildException('No apks match %s' % globpath)
735         src = os.path.normpath(apks[0])
736
737     # Make sure it's not debuggable...
738     if common.isApkAndDebuggable(src, config):
739         raise BuildException("APK is debuggable")
740
741     # By way of a sanity check, make sure the version and version
742     # code in our new apk match what we expect...
743     logging.debug("Checking " + src)
744     if not os.path.exists(src):
745         raise BuildException("Unsigned apk is not at expected location of " + src)
746
747     if common.get_file_extension(src) == 'apk':
748         vercode, version = get_metadata_from_apk(app, build, src)
749         if (version != build.versionName or vercode != build.versionCode):
750             raise BuildException(("Unexpected version/version code in output;"
751                                   " APK: '%s' / '%s', "
752                                   " Expected: '%s' / '%s'")
753                                  % (version, str(vercode), build.versionName,
754                                     str(build.versionCode)))
755     else:
756         vercode = build.versionCode
757         version = build.versionName
758
759     # Add information for 'fdroid verify' to be able to reproduce the build
760     # environment.
761     if onserver:
762         metadir = os.path.join(tmp_dir, 'META-INF')
763         if not os.path.exists(metadir):
764             os.mkdir(metadir)
765         homedir = os.path.expanduser('~')
766         for fn in ['buildserverid', 'fdroidserverid']:
767             shutil.copyfile(os.path.join(homedir, fn),
768                             os.path.join(metadir, fn))
769             subprocess.call(['jar', 'uf', os.path.abspath(src),
770                              'META-INF/' + fn], cwd=tmp_dir)
771
772     # Copy the unsigned apk to our destination directory for further
773     # processing (by publish.py)...
774     dest = os.path.join(output_dir, common.get_release_filename(app, build))
775     shutil.copyfile(src, dest)
776
777     # Move the source tarball into the output directory...
778     if output_dir != tmp_dir and not options.notarball:
779         shutil.move(os.path.join(tmp_dir, tarname),
780                     os.path.join(output_dir, tarname))
781
782
783 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
784              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
785              server, force, onserver, refresh):
786     """
787     Build a particular version of an application, if it needs building.
788
789     :param output_dir: The directory where the build output will go. Usually
790        this is the 'unsigned' directory.
791     :param repo_dir: The repo directory - used for checking if the build is
792        necessary.
793     :param also_check_dir: An additional location for checking if the build
794        is necessary (usually the archive repo)
795     :param test: True if building in test mode, in which case the build will
796        always happen, even if the output already exists. In test mode, the
797        output directory should be a temporary location, not any of the real
798        ones.
799
800     :returns: True if the build was done, False if it wasn't necessary.
801     """
802
803     dest_file = common.get_release_filename(app, build)
804
805     dest = os.path.join(output_dir, dest_file)
806     dest_repo = os.path.join(repo_dir, dest_file)
807
808     if not test:
809         if os.path.exists(dest) or os.path.exists(dest_repo):
810             return False
811
812         if also_check_dir:
813             dest_also = os.path.join(also_check_dir, dest_file)
814             if os.path.exists(dest_also):
815                 return False
816
817     if build.disable and not options.force:
818         return False
819
820     logging.info("Building version %s (%s) of %s" % (
821         build.versionName, build.versionCode, app.id))
822
823     if server:
824         # When using server mode, still keep a local cache of the repo, by
825         # grabbing the source now.
826         vcs.gotorevision(build.commit)
827
828         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
829     else:
830         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
831     return True
832
833
834 def get_android_tools_versions(ndk_path=None):
835     '''get a list of the versions of all installed Android SDK/NDK components'''
836
837     global config
838     sdk_path = config['sdk_path']
839     if sdk_path[-1] != '/':
840         sdk_path += '/'
841     components = []
842     if ndk_path:
843         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
844         if os.path.isfile(ndk_release_txt):
845             with open(ndk_release_txt, 'r') as fp:
846                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
847
848     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
849     for root, dirs, files in os.walk(sdk_path):
850         if 'source.properties' in files:
851             source_properties = os.path.join(root, 'source.properties')
852             with open(source_properties, 'r') as fp:
853                 m = pattern.search(fp.read())
854                 if m:
855                     components.append((root[len(sdk_path):], m.group(1)))
856
857     return components
858
859
860 def get_android_tools_version_log(ndk_path):
861     '''get a list of the versions of all installed Android SDK/NDK components'''
862     log = '== Installed Android Tools ==\n\n'
863     components = get_android_tools_versions(ndk_path)
864     for name, version in sorted(components):
865         log += '* ' + name + ' (' + version + ')\n'
866
867     return log
868
869
870 def parse_commandline():
871     """Parse the command line. Returns options, parser."""
872
873     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
874     common.setup_global_opts(parser)
875     parser.add_argument("appid", nargs='*', help="app-id with optional versionCode in the form APPID[:VERCODE]")
876     parser.add_argument("-l", "--latest", action="store_true", default=False,
877                         help="Build only the latest version of each package")
878     parser.add_argument("-s", "--stop", action="store_true", default=False,
879                         help="Make the build stop on exceptions")
880     parser.add_argument("-t", "--test", action="store_true", default=False,
881                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
882     parser.add_argument("--server", action="store_true", default=False,
883                         help="Use build server")
884     parser.add_argument("--resetserver", action="store_true", default=False,
885                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
886     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
887                         help="Specify that we're running on the build server")
888     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
889                         help="Skip scanning the source code for binaries and other problems")
890     parser.add_argument("--dscanner", action="store_true", default=False,
891                         help="Setup an emulator, install the apk on it and perform a drozer scan")
892     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
893                         help="Don't create a source tarball, useful when testing a build")
894     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
895                         help="Don't refresh the repository, useful when testing a build with no internet connection")
896     parser.add_argument("-f", "--force", action="store_true", default=False,
897                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
898     parser.add_argument("-a", "--all", action="store_true", default=False,
899                         help="Build all applications available")
900     parser.add_argument("-w", "--wiki", default=False, action="store_true",
901                         help="Update the wiki")
902     metadata.add_metadata_arguments(parser)
903     options = parser.parse_args()
904     metadata.warnings_action = options.W
905
906     # Force --stop with --on-server to get correct exit code
907     if options.onserver:
908         options.stop = True
909
910     if options.force and not options.test:
911         parser.error("option %s: Force is only allowed in test mode" % "force")
912
913     return options, parser
914
915
916 options = None
917 config = None
918 buildserverid = None
919
920
921 def main():
922
923     global options, config, buildserverid
924
925     options, parser = parse_commandline()
926
927     # The defaults for .fdroid.* metadata that is included in a git repo are
928     # different than for the standard metadata/ layout because expectations
929     # are different.  In this case, the most common user will be the app
930     # developer working on the latest update of the app on their own machine.
931     local_metadata_files = common.get_local_metadata_files()
932     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
933         config = dict(common.default_config)
934         # `fdroid build` should build only the latest version by default since
935         # most of the time the user will be building the most recent update
936         if not options.all:
937             options.latest = True
938     elif len(local_metadata_files) > 1:
939         raise FDroidException("Only one local metadata file allowed! Found: "
940                               + " ".join(local_metadata_files))
941     else:
942         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
943             raise FDroidException("No app metadata found, nothing to process!")
944         if not options.appid and not options.all:
945             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
946
947     config = common.read_config(options)
948
949     if config['build_server_always']:
950         options.server = True
951     if options.resetserver and not options.server:
952         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
953
954     log_dir = 'logs'
955     if not os.path.isdir(log_dir):
956         logging.info("Creating log directory")
957         os.makedirs(log_dir)
958
959     tmp_dir = 'tmp'
960     if not os.path.isdir(tmp_dir):
961         logging.info("Creating temporary directory")
962         os.makedirs(tmp_dir)
963
964     if options.test:
965         output_dir = tmp_dir
966     else:
967         output_dir = 'unsigned'
968         if not os.path.isdir(output_dir):
969             logging.info("Creating output directory")
970             os.makedirs(output_dir)
971
972     if config['archive_older'] != 0:
973         also_check_dir = 'archive'
974     else:
975         also_check_dir = None
976
977     repo_dir = 'repo'
978
979     build_dir = 'build'
980     if not os.path.isdir(build_dir):
981         logging.info("Creating build directory")
982         os.makedirs(build_dir)
983     srclib_dir = os.path.join(build_dir, 'srclib')
984     extlib_dir = os.path.join(build_dir, 'extlib')
985
986     # Read all app and srclib metadata
987     pkgs = common.read_pkg_args(options.appid, True)
988     allapps = metadata.read_metadata(not options.onserver, pkgs)
989     apps = common.read_app_args(options.appid, allapps, True)
990
991     for appid, app in list(apps.items()):
992         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
993             del apps[appid]
994
995     if not apps:
996         raise FDroidException("No apps to process.")
997
998     if options.latest:
999         for app in apps.values():
1000             for build in reversed(app.builds):
1001                 if build.disable and not options.force:
1002                     continue
1003                 app.builds = [build]
1004                 break
1005
1006     if options.wiki:
1007         import mwclient
1008         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1009                              path=config['wiki_path'])
1010         site.login(config['wiki_user'], config['wiki_password'])
1011
1012     # Build applications...
1013     failed_apps = {}
1014     build_succeeded = []
1015     for appid, app in apps.items():
1016
1017         first = True
1018
1019         for build in app.builds:
1020             wikilog = None
1021             tools_version_log = ''
1022             if not options.onserver:
1023                 tools_version_log = get_android_tools_version_log(build.ndk_path())
1024             try:
1025
1026                 # For the first build of a particular app, we need to set up
1027                 # the source repo. We can reuse it on subsequent builds, if
1028                 # there are any.
1029                 if first:
1030                     vcs, build_dir = common.setup_vcs(app)
1031                     first = False
1032
1033                 logging.debug("Checking " + build.versionName)
1034                 if trybuild(app, build, build_dir, output_dir, log_dir,
1035                             also_check_dir, srclib_dir, extlib_dir,
1036                             tmp_dir, repo_dir, vcs, options.test,
1037                             options.server, options.force,
1038                             options.onserver, options.refresh):
1039                     toolslog = os.path.join(log_dir,
1040                                             common.get_toolsversion_logname(app, build))
1041                     if not options.onserver and os.path.exists(toolslog):
1042                         with open(toolslog, 'r') as f:
1043                             tools_version_log = ''.join(f.readlines())
1044                         os.remove(toolslog)
1045
1046                     if app.Binaries is not None:
1047                         # This is an app where we build from source, and
1048                         # verify the apk contents against a developer's
1049                         # binary. We get that binary now, and save it
1050                         # alongside our built one in the 'unsigend'
1051                         # directory.
1052                         url = app.Binaries
1053                         url = url.replace('%v', build.versionName)
1054                         url = url.replace('%c', str(build.versionCode))
1055                         logging.info("...retrieving " + url)
1056                         of = common.get_release_filename(app, build) + '.binary'
1057                         of = os.path.join(output_dir, of)
1058                         try:
1059                             net.download_file(url, local_filename=of)
1060                         except requests.exceptions.HTTPError as e:
1061                             raise FDroidException(
1062                                 'Downloading Binaries from %s failed. %s' % (url, e))
1063
1064                         # Now we check weather the build can be verified to
1065                         # match the supplied binary or not. Should the
1066                         # comparison fail, we mark this build as a failure
1067                         # and remove everything from the unsigend folder.
1068                         with tempfile.TemporaryDirectory() as tmpdir:
1069                             unsigned_apk = \
1070                                 common.get_release_filename(app, build)
1071                             unsigned_apk = \
1072                                 os.path.join(output_dir, unsigned_apk)
1073                             compare_result = \
1074                                 common.verify_apks(of, unsigned_apk, tmpdir)
1075                             if compare_result:
1076                                 logging.debug('removing %s', unsigned_apk)
1077                                 os.remove(unsigned_apk)
1078                                 logging.debug('removing %s', of)
1079                                 os.remove(of)
1080                                 compare_result = compare_result.split('\n')
1081                                 line_count = len(compare_result)
1082                                 compare_result = compare_result[:299]
1083                                 if line_count > len(compare_result):
1084                                     line_difference = \
1085                                         line_count - len(compare_result)
1086                                     compare_result.append('%d more lines ...' %
1087                                                           line_difference)
1088                                 compare_result = '\n'.join(compare_result)
1089                                 raise FDroidException('compared built binary '
1090                                                       'to supplied reference '
1091                                                       'binary but failed',
1092                                                       compare_result)
1093                             else:
1094                                 logging.info('compared built binary to '
1095                                              'supplied reference binary '
1096                                              'successfully')
1097
1098                     build_succeeded.append(app)
1099                     wikilog = "Build succeeded"
1100
1101             except VCSException as vcse:
1102                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1103                 logging.error("VCS error while building app %s: %s" % (
1104                     appid, reason))
1105                 if options.stop:
1106                     sys.exit(1)
1107                 failed_apps[appid] = vcse
1108                 wikilog = str(vcse)
1109             except FDroidException as e:
1110                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1111                     f.write('\n\n============================================================\n')
1112                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1113                             (build.versionCode, build.versionName, build.commit))
1114                     f.write('Build completed at '
1115                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1116                     f.write('\n' + tools_version_log + '\n')
1117                     f.write(str(e))
1118                 logging.error("Could not build app %s: %s" % (appid, e))
1119                 if options.stop:
1120                     sys.exit(1)
1121                 failed_apps[appid] = e
1122                 wikilog = e.get_wikitext()
1123             except Exception as e:
1124                 logging.error("Could not build app %s due to unknown error: %s" % (
1125                     appid, traceback.format_exc()))
1126                 if options.stop:
1127                     sys.exit(1)
1128                 failed_apps[appid] = e
1129                 wikilog = str(e)
1130
1131             if options.wiki and wikilog:
1132                 try:
1133                     # Write a page with the last build log for this version code
1134                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1135                     newpage = site.Pages[lastbuildpage]
1136                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1137                         fdroidserverid = fp.read().rstrip()
1138                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1139                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1140                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1141                     if options.onserver:
1142                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1143                                + buildserverid + ' ' + buildserverid + ']\n\n'
1144                     txt += tools_version_log + '\n\n'
1145                     txt += '== Build Log ==\n\n' + wikilog
1146                     newpage.save(txt, summary='Build log')
1147                     # Redirect from /lastbuild to the most recent build log
1148                     newpage = site.Pages[appid + '/lastbuild']
1149                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1150                 except Exception as e:
1151                     logging.error("Error while attempting to publish build log: %s" % e)
1152
1153     for app in build_succeeded:
1154         logging.info("success: %s" % (app.id))
1155
1156     if not options.verbose:
1157         for fa in failed_apps:
1158             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1159
1160     # perform a drozer scan of all successful builds
1161     if options.dscanner and build_succeeded:
1162         from .dscanner import DockerDriver
1163
1164         docker = DockerDriver()
1165
1166         try:
1167             for app in build_succeeded:
1168
1169                 logging.info("Need to sign the app before we can install it.")
1170                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1171
1172                 apk_path = None
1173
1174                 for f in os.listdir(repo_dir):
1175                     if f.endswith('.apk') and f.startswith(app.id):
1176                         apk_path = os.path.join(repo_dir, f)
1177                         break
1178
1179                 if not apk_path:
1180                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1181
1182                 if not os.path.isdir(repo_dir):
1183                     exit(1)
1184
1185                 logging.info("Performing Drozer scan on {0}.".format(app))
1186                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1187         except Exception as e:
1188             logging.error(str(e))
1189             logging.error("An exception happened. Making sure to clean up")
1190         else:
1191             logging.info("Scan succeeded.")
1192
1193         logging.info("Cleaning up after ourselves.")
1194         docker.clean()
1195
1196     logging.info("Finished.")
1197     if len(build_succeeded) > 0:
1198         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1199     if len(failed_apps) > 0:
1200         logging.info(str(len(failed_apps)) + ' builds failed')
1201
1202     sys.exit(0)
1203
1204
1205 if __name__ == "__main__":
1206     main()