chiark / gitweb /
Use any of the branches that point to origin/HEAD if there are multiple
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
2 #
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, 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 os
21 import sys
22 import re
23 import shutil
24 import glob
25 import stat
26 import subprocess
27 import time
28 import operator
29 import Queue
30 import threading
31 import magic
32 import logging
33 from distutils.version import LooseVersion
34
35 import metadata
36
37 config = None
38 options = None
39 env = None
40
41
42 def get_default_config():
43     return {
44         'sdk_path': os.getenv("ANDROID_HOME") or "",
45         'ndk_path': os.getenv("ANDROID_NDK") or "",
46         'build_tools': "20.0.0",
47         'ant': "ant",
48         'mvn3': "mvn",
49         'gradle': 'gradle',
50         'sync_from_local_copy_dir': False,
51         'update_stats': False,
52         'stats_to_carbon': False,
53         'repo_maxage': 0,
54         'build_server_always': False,
55         'keystore': os.path.join(os.getenv("HOME"), '.local', 'share', 'fdroidserver', 'keystore.jks'),
56         'smartcardoptions': [],
57         'char_limits': {
58             'Summary': 50,
59             'Description': 1500
60         },
61         'keyaliases': {},
62         'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
63         'repo_name': "My First FDroid Repo Demo",
64         'repo_icon': "fdroid-icon.png",
65         'repo_description': (
66             "This is a repository of apps to be used with FDroid. Applications in this "
67             + "repository are either official binaries built by the original application "
68             + "developers, or are binaries built from source by the admin of f-droid.org "
69             + "using the tools on https://gitlab.com/u/fdroid."),
70         'archive_older': 0,
71     }
72
73
74 def read_config(opts, config_file='config.py'):
75     """Read the repository config
76
77     The config is read from config_file, which is in the current directory when
78     any of the repo management commands are used.
79     """
80     global config, options, env
81
82     if config is not None:
83         return config
84     if not os.path.isfile(config_file):
85         logging.critical("Missing config file - is this a repo directory?")
86         sys.exit(2)
87
88     options = opts
89
90     config = {}
91
92     logging.debug("Reading %s" % config_file)
93     execfile(config_file, config)
94
95     # smartcardoptions must be a list since its command line args for Popen
96     if 'smartcardoptions' in config:
97         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
98     elif 'keystore' in config and config['keystore'] == 'NONE':
99         # keystore='NONE' means use smartcard, these are required defaults
100         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
101                                       'SunPKCS11-OpenSC', '-providerClass',
102                                       'sun.security.pkcs11.SunPKCS11',
103                                       '-providerArg', 'opensc-fdroid.cfg']
104
105     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
106         st = os.stat(config_file)
107         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
108             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
109
110     defconfig = get_default_config()
111     for k, v in defconfig.items():
112         if k not in config:
113             config[k] = v
114
115     # Expand environment variables
116     for k, v in config.items():
117         if type(v) != str:
118             continue
119         v = os.path.expanduser(v)
120         config[k] = os.path.expandvars(v)
121
122     if not test_sdk_exists(config):
123         sys.exit(3)
124
125     if not test_build_tools_exists(config):
126         sys.exit(3)
127
128     bin_paths = {
129         'aapt': [
130             os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'aapt'),
131             ],
132         'zipalign': [
133             os.path.join(config['sdk_path'], 'tools', 'zipalign'),
134             os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'zipalign'),
135             ],
136         'android': [
137             os.path.join(config['sdk_path'], 'tools', 'android'),
138             ],
139         }
140
141     for b, paths in bin_paths.items():
142         config[b] = None
143         for path in paths:
144             if os.path.isfile(path):
145                 config[b] = path
146                 break
147         if config[b] is None:
148             logging.warn("Could not find %s in any of the following paths:\n%s" % (
149                 b, '\n'.join(paths)))
150
151     # There is no standard, so just set up the most common environment
152     # variables
153     env = os.environ
154     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
155         env[n] = config['sdk_path']
156     for n in ['ANDROID_NDK', 'NDK']:
157         env[n] = config['ndk_path']
158
159     for k in ["keystorepass", "keypass"]:
160         if k in config:
161             write_password_file(k)
162
163     # since this is used with rsync, where trailing slashes have meaning,
164     # ensure there is always a trailing slash
165     if 'serverwebroot' in config:
166         if config['serverwebroot'][-1] != '/':
167             config['serverwebroot'] += '/'
168         config['serverwebroot'] = config['serverwebroot'].replace('//', '/')
169
170     return config
171
172
173 def test_sdk_exists(c):
174     if c['sdk_path'] is None:
175         # c['sdk_path'] is set to the value of ANDROID_HOME by default
176         logging.error('No Android SDK found! ANDROID_HOME is not set and sdk_path is not in config.py!')
177         logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
178         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
179         return False
180     if not os.path.exists(c['sdk_path']):
181         logging.critical('Android SDK path "' + c['sdk_path'] + '" does not exist!')
182         return False
183     if not os.path.isdir(c['sdk_path']):
184         logging.critical('Android SDK path "' + c['sdk_path'] + '" is not a directory!')
185         return False
186     if not os.path.isdir(os.path.join(c['sdk_path'], 'build-tools')):
187         logging.critical('Android SDK path "' + c['sdk_path'] + '" does not contain "build-tools/"!')
188         return False
189     return True
190
191
192 def test_build_tools_exists(c):
193     if not test_sdk_exists(c):
194         return False
195     build_tools = os.path.join(c['sdk_path'], 'build-tools')
196     versioned_build_tools = os.path.join(build_tools, c['build_tools'])
197     if not os.path.isdir(versioned_build_tools):
198         logging.critical('Android Build Tools path "'
199                          + versioned_build_tools + '" does not exist!')
200         return False
201     return True
202
203
204 def write_password_file(pwtype, password=None):
205     '''
206     writes out passwords to a protected file instead of passing passwords as
207     command line argments
208     '''
209     filename = '.fdroid.' + pwtype + '.txt'
210     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
211     if password is None:
212         os.write(fd, config[pwtype])
213     else:
214         os.write(fd, password)
215     os.close(fd)
216     config[pwtype + 'file'] = filename
217
218
219 # Given the arguments in the form of multiple appid:[vc] strings, this returns
220 # a dictionary with the set of vercodes specified for each package.
221 def read_pkg_args(args, allow_vercodes=False):
222
223     vercodes = {}
224     if not args:
225         return vercodes
226
227     for p in args:
228         if allow_vercodes and ':' in p:
229             package, vercode = p.split(':')
230         else:
231             package, vercode = p, None
232         if package not in vercodes:
233             vercodes[package] = [vercode] if vercode else []
234             continue
235         elif vercode and vercode not in vercodes[package]:
236             vercodes[package] += [vercode] if vercode else []
237
238     return vercodes
239
240
241 # On top of what read_pkg_args does, this returns the whole app metadata, but
242 # limiting the builds list to the builds matching the vercodes specified.
243 def read_app_args(args, allapps, allow_vercodes=False):
244
245     vercodes = read_pkg_args(args, allow_vercodes)
246
247     if not vercodes:
248         return allapps
249
250     apps = [app for app in allapps if app['id'] in vercodes]
251
252     if len(apps) != len(vercodes):
253         allids = [app["id"] for app in allapps]
254         for p in vercodes:
255             if p not in allids:
256                 logging.critical("No such package: %s" % p)
257         raise Exception("Found invalid app ids in arguments")
258     if not apps:
259         raise Exception("No packages specified")
260
261     error = False
262     for app in apps:
263         vc = vercodes[app['id']]
264         if not vc:
265             continue
266         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
267         if len(app['builds']) != len(vercodes[app['id']]):
268             error = True
269             allvcs = [b['vercode'] for b in app['builds']]
270             for v in vercodes[app['id']]:
271                 if v not in allvcs:
272                     logging.critical("No such vercode %s for app %s" % (v, app['id']))
273
274     if error:
275         raise Exception("Found invalid vercodes for some apps")
276
277     return apps
278
279
280 def has_extension(filename, extension):
281     name, ext = os.path.splitext(filename)
282     ext = ext.lower()[1:]
283     return ext == extension
284
285 apk_regex = None
286
287
288 def apknameinfo(filename):
289     global apk_regex
290     filename = os.path.basename(filename)
291     if apk_regex is None:
292         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
293     m = apk_regex.match(filename)
294     try:
295         result = (m.group(1), m.group(2))
296     except AttributeError:
297         raise Exception("Invalid apk name: %s" % filename)
298     return result
299
300
301 def getapkname(app, build):
302     return "%s_%s.apk" % (app['id'], build['vercode'])
303
304
305 def getsrcname(app, build):
306     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
307
308
309 def getappname(app):
310     if app['Name']:
311         return app['Name']
312     if app['Auto Name']:
313         return app['Auto Name']
314     return app['id']
315
316
317 def getcvname(app):
318     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
319
320
321 def getvcs(vcstype, remote, local):
322     if vcstype == 'git':
323         return vcs_git(remote, local)
324     if vcstype == 'svn':
325         return vcs_svn(remote, local)
326     if vcstype == 'git-svn':
327         return vcs_gitsvn(remote, local)
328     if vcstype == 'hg':
329         return vcs_hg(remote, local)
330     if vcstype == 'bzr':
331         return vcs_bzr(remote, local)
332     if vcstype == 'srclib':
333         if local != 'build/srclib/' + remote:
334             raise VCSException("Error: srclib paths are hard-coded!")
335         return getsrclib(remote, 'build/srclib', raw=True)
336     raise VCSException("Invalid vcs type " + vcstype)
337
338
339 def getsrclibvcs(name):
340     if name not in metadata.srclibs:
341         raise VCSException("Missing srclib " + name)
342     return metadata.srclibs[name]['Repo Type']
343
344
345 class vcs:
346     def __init__(self, remote, local):
347
348         # svn, git-svn and bzr may require auth
349         self.username = None
350         if self.repotype() in ('svn', 'git-svn', 'bzr'):
351             if '@' in remote:
352                 self.username, remote = remote.split('@')
353                 if ':' not in self.username:
354                     raise VCSException("Password required with username")
355                 self.username, self.password = self.username.split(':')
356
357         self.remote = remote
358         self.local = local
359         self.clone_failed = False
360         self.refreshed = False
361         self.srclib = None
362
363     def repotype(self):
364         return None
365
366     # Take the local repository to a clean version of the given revision, which
367     # is specificed in the VCS's native format. Beforehand, the repository can
368     # be dirty, or even non-existent. If the repository does already exist
369     # locally, it will be updated from the origin, but only once in the
370     # lifetime of the vcs object.
371     # None is acceptable for 'rev' if you know you are cloning a clean copy of
372     # the repo - otherwise it must specify a valid revision.
373     def gotorevision(self, rev):
374
375         if self.clone_failed:
376             raise VCSException("Downloading the repository already failed once, not trying again.")
377
378         # The .fdroidvcs-id file for a repo tells us what VCS type
379         # and remote that directory was created from, allowing us to drop it
380         # automatically if either of those things changes.
381         fdpath = os.path.join(self.local, '..',
382                               '.fdroidvcs-' + os.path.basename(self.local))
383         cdata = self.repotype() + ' ' + self.remote
384         writeback = True
385         deleterepo = False
386         if os.path.exists(self.local):
387             if os.path.exists(fdpath):
388                 with open(fdpath, 'r') as f:
389                     fsdata = f.read().strip()
390                 if fsdata == cdata:
391                     writeback = False
392                 else:
393                     deleterepo = True
394                     logging.info(
395                         "Repository details for %s changed - deleting" % (
396                             self.local))
397             else:
398                 deleterepo = True
399                 logging.info("Repository details for %s missing - deleting" % (
400                     self.local))
401         if deleterepo:
402             shutil.rmtree(self.local)
403
404         self.gotorevisionx(rev)
405
406         # If necessary, write the .fdroidvcs file.
407         if writeback:
408             with open(fdpath, 'w') as f:
409                 f.write(cdata)
410
411     # Derived classes need to implement this. It's called once basic checking
412     # has been performend.
413     def gotorevisionx(self, rev):
414         raise VCSException("This VCS type doesn't define gotorevisionx")
415
416     # Initialise and update submodules
417     def initsubmodules(self):
418         raise VCSException('Submodules not supported for this vcs type')
419
420     # Get a list of all known tags
421     def gettags(self):
422         raise VCSException('gettags not supported for this vcs type')
423
424     # Get a list of latest number tags
425     def latesttags(self, number):
426         raise VCSException('latesttags not supported for this vcs type')
427
428     # Get current commit reference (hash, revision, etc)
429     def getref(self):
430         raise VCSException('getref not supported for this vcs type')
431
432     # Returns the srclib (name, path) used in setting up the current
433     # revision, or None.
434     def getsrclib(self):
435         return self.srclib
436
437
438 class vcs_git(vcs):
439
440     def repotype(self):
441         return 'git'
442
443     # If the local directory exists, but is somehow not a git repository, git
444     # will traverse up the directory tree until it finds one that is (i.e.
445     # fdroidserver) and then we'll proceed to destroy it! This is called as
446     # a safety check.
447     def checkrepo(self):
448         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
449         result = p.output.rstrip()
450         if not result.endswith(self.local):
451             raise VCSException('Repository mismatch')
452
453     def gotorevisionx(self, rev):
454         if not os.path.exists(self.local):
455             # Brand new checkout
456             p = FDroidPopen(['git', 'clone', self.remote, self.local])
457             if p.returncode != 0:
458                 self.clone_failed = True
459                 raise VCSException("Git clone failed", p.output)
460             self.checkrepo()
461         else:
462             self.checkrepo()
463             # Discard any working tree changes
464             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
465             if p.returncode != 0:
466                 raise VCSException("Git reset failed", p.output)
467             # Remove untracked files now, in case they're tracked in the target
468             # revision (it happens!)
469             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
470             if p.returncode != 0:
471                 raise VCSException("Git clean failed", p.output)
472             if not self.refreshed:
473                 # Get latest commits and tags from remote
474                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
475                 if p.returncode != 0:
476                     raise VCSException("Git fetch failed", p.output)
477                 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
478                 if p.returncode != 0:
479                     raise VCSException("Git fetch failed", p.output)
480                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
481                 p = SilentPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local)
482                 if p.returncode != 0:
483                     lines = p.output.splitlines()
484                     if 'Multiple remote HEAD branches' not in lines[0]:
485                         raise VCSException("Git remote set-head failed", p.output)
486                     branch = lines[1].split(' ')[-1]
487                     p2 = SilentPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local)
488                     if p2.returncode != 0:
489                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
490                 self.refreshed = True
491         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
492         # a github repo. Most of the time this is the same as origin/master.
493         rev = str(rev if rev else 'origin/HEAD')
494         p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
495         if p.returncode != 0:
496             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
497         # Get rid of any uncontrolled files left behind
498         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
499         if p.returncode != 0:
500             raise VCSException("Git clean failed", p.output)
501
502     def initsubmodules(self):
503         self.checkrepo()
504         submfile = os.path.join(self.local, '.gitmodules')
505         if not os.path.isfile(submfile):
506             raise VCSException("No git submodules available")
507
508         # fix submodules not accessible without an account and public key auth
509         with open(submfile, 'r') as f:
510             lines = f.readlines()
511         with open(submfile, 'w') as f:
512             for line in lines:
513                 if 'git@github.com' in line:
514                     line = line.replace('git@github.com:', 'https://github.com/')
515                 f.write(line)
516
517         for cmd in [
518                 ['git', 'reset', '--hard'],
519                 ['git', 'clean', '-dffx'],
520                 ]:
521             p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
522             if p.returncode != 0:
523                 raise VCSException("Git submodule reset failed", p.output)
524         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local)
525         if p.returncode != 0:
526             raise VCSException("Git submodule sync failed", p.output)
527         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
528         if p.returncode != 0:
529             raise VCSException("Git submodule update failed", p.output)
530
531     def gettags(self):
532         self.checkrepo()
533         p = SilentPopen(['git', 'tag'], cwd=self.local)
534         return p.output.splitlines()
535
536     def latesttags(self, alltags, number):
537         self.checkrepo()
538         p = SilentPopen(['echo "' + '\n'.join(alltags) + '" | '
539                         + 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
540                         + 'sort -n | awk \'{print $2}\''],
541                         cwd=self.local, shell=True)
542         return p.output.splitlines()[-number:]
543
544
545 class vcs_gitsvn(vcs):
546
547     def repotype(self):
548         return 'git-svn'
549
550     # Damn git-svn tries to use a graphical password prompt, so we have to
551     # trick it into taking the password from stdin
552     def userargs(self):
553         if self.username is None:
554             return ('', '')
555         return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
556
557     # If the local directory exists, but is somehow not a git repository, git
558     # will traverse up the directory tree until it finds one that is (i.e.
559     # fdroidserver) and then we'll proceed to destory it! This is called as
560     # a safety check.
561     def checkrepo(self):
562         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
563         result = p.output.rstrip()
564         if not result.endswith(self.local):
565             raise VCSException('Repository mismatch')
566
567     def gotorevisionx(self, rev):
568         if not os.path.exists(self.local):
569             # Brand new checkout
570             gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
571             if ';' in self.remote:
572                 remote_split = self.remote.split(';')
573                 for i in remote_split[1:]:
574                     if i.startswith('trunk='):
575                         gitsvn_cmd += ' -T %s' % i[6:]
576                     elif i.startswith('tags='):
577                         gitsvn_cmd += ' -t %s' % i[5:]
578                     elif i.startswith('branches='):
579                         gitsvn_cmd += ' -b %s' % i[9:]
580                 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
581                 if p.returncode != 0:
582                     self.clone_failed = True
583                     raise VCSException("Git clone failed", p.output)
584             else:
585                 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
586                 if p.returncode != 0:
587                     self.clone_failed = True
588                     raise VCSException("Git clone failed", p.output)
589             self.checkrepo()
590         else:
591             self.checkrepo()
592             # Discard any working tree changes
593             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
594             if p.returncode != 0:
595                 raise VCSException("Git reset failed", p.output)
596             # Remove untracked files now, in case they're tracked in the target
597             # revision (it happens!)
598             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
599             if p.returncode != 0:
600                 raise VCSException("Git clean failed", p.output)
601             if not self.refreshed:
602                 # Get new commits, branches and tags from repo
603                 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
604                 if p.returncode != 0:
605                     raise VCSException("Git svn fetch failed")
606                 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
607                 if p.returncode != 0:
608                     raise VCSException("Git svn rebase failed", p.output)
609                 self.refreshed = True
610
611         rev = str(rev if rev else 'master')
612         if rev:
613             nospaces_rev = rev.replace(' ', '%20')
614             # Try finding a svn tag
615             p = SilentPopen(['git', 'checkout', 'tags/' + nospaces_rev], cwd=self.local)
616             if p.returncode != 0:
617                 # No tag found, normal svn rev translation
618                 # Translate svn rev into git format
619                 rev_split = rev.split('/')
620                 if len(rev_split) > 1:
621                     treeish = 'origin/' + rev_split[0]
622                     svn_rev = rev_split[1]
623
624                 else:
625                     # if no branch is specified, then assume trunk (ie. 'master'
626                     # branch):
627                     treeish = 'origin/master'
628                     svn_rev = rev
629
630                 p = SilentPopen(['git', 'svn', 'find-rev', 'r' + svn_rev, treeish], cwd=self.local)
631                 git_rev = p.output.rstrip()
632
633                 if p.returncode != 0 or not git_rev:
634                     # Try a plain git checkout as a last resort
635                     p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
636                     if p.returncode != 0:
637                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
638                 else:
639                     # Check out the git rev equivalent to the svn rev
640                     p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
641                     if p.returncode != 0:
642                         raise VCSException("Git svn checkout of '%s' failed" % rev, p.output)
643
644         # Get rid of any uncontrolled files left behind
645         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
646         if p.returncode != 0:
647             raise VCSException("Git clean failed", p.output)
648
649     def gettags(self):
650         self.checkrepo()
651         return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
652
653     def getref(self):
654         self.checkrepo()
655         p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
656         if p.returncode != 0:
657             return None
658         return p.output.strip()
659
660
661 class vcs_svn(vcs):
662
663     def repotype(self):
664         return 'svn'
665
666     def userargs(self):
667         if self.username is None:
668             return ['--non-interactive']
669         return ['--username', self.username,
670                 '--password', self.password,
671                 '--non-interactive']
672
673     def gotorevisionx(self, rev):
674         if not os.path.exists(self.local):
675             p = SilentPopen(['svn', 'checkout', self.remote, self.local] + self.userargs())
676             if p.returncode != 0:
677                 self.clone_failed = True
678                 raise VCSException("Svn checkout of '%s' failed" % rev, p.output)
679         else:
680             for svncommand in (
681                     'svn revert -R .',
682                     r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
683                 p = SilentPopen([svncommand], cwd=self.local, shell=True)
684                 if p.returncode != 0:
685                     raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local), p.output)
686             if not self.refreshed:
687                 p = SilentPopen(['svn', 'update'] + self.userargs(), cwd=self.local)
688                 if p.returncode != 0:
689                     raise VCSException("Svn update failed", p.output)
690                 self.refreshed = True
691
692         revargs = list(['-r', rev] if rev else [])
693         p = SilentPopen(['svn', 'update', '--force'] + revargs + self.userargs(), cwd=self.local)
694         if p.returncode != 0:
695             raise VCSException("Svn update failed", p.output)
696
697     def getref(self):
698         p = SilentPopen(['svn', 'info'], cwd=self.local)
699         for line in p.output.splitlines():
700             if line and line.startswith('Last Changed Rev: '):
701                 return line[18:]
702         return None
703
704
705 class vcs_hg(vcs):
706
707     def repotype(self):
708         return 'hg'
709
710     def gotorevisionx(self, rev):
711         if not os.path.exists(self.local):
712             p = SilentPopen(['hg', 'clone', self.remote, self.local])
713             if p.returncode != 0:
714                 self.clone_failed = True
715                 raise VCSException("Hg clone failed", p.output)
716         else:
717             p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
718             if p.returncode != 0:
719                 raise VCSException("Hg clean failed", p.output)
720             if not self.refreshed:
721                 p = SilentPopen(['hg', 'pull'], cwd=self.local)
722                 if p.returncode != 0:
723                     raise VCSException("Hg pull failed", p.output)
724                 self.refreshed = True
725
726         rev = str(rev if rev else 'default')
727         if not rev:
728             return
729         p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
730         if p.returncode != 0:
731             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
732         p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
733         # Also delete untracked files, we have to enable purge extension for that:
734         if "'purge' is provided by the following extension" in p.output:
735             with open(self.local + "/.hg/hgrc", "a") as myfile:
736                 myfile.write("\n[extensions]\nhgext.purge=\n")
737             p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
738             if p.returncode != 0:
739                 raise VCSException("HG purge failed", p.output)
740         elif p.returncode != 0:
741             raise VCSException("HG purge failed", p.output)
742
743     def gettags(self):
744         p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
745         return p.output.splitlines()[1:]
746
747
748 class vcs_bzr(vcs):
749
750     def repotype(self):
751         return 'bzr'
752
753     def gotorevisionx(self, rev):
754         if not os.path.exists(self.local):
755             p = SilentPopen(['bzr', 'branch', self.remote, self.local])
756             if p.returncode != 0:
757                 self.clone_failed = True
758                 raise VCSException("Bzr branch failed", p.output)
759         else:
760             p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
761             if p.returncode != 0:
762                 raise VCSException("Bzr revert failed", p.output)
763             if not self.refreshed:
764                 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
765                 if p.returncode != 0:
766                     raise VCSException("Bzr update failed", p.output)
767                 self.refreshed = True
768
769         revargs = list(['-r', rev] if rev else [])
770         p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
771         if p.returncode != 0:
772             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
773
774     def gettags(self):
775         p = SilentPopen(['bzr', 'tags'], cwd=self.local)
776         return [tag.split('   ')[0].strip() for tag in
777                 p.output.splitlines()]
778
779
780 def retrieve_string(app_dir, string, xmlfiles=None):
781
782     res_dirs = [
783         os.path.join(app_dir, 'res'),
784         os.path.join(app_dir, 'src/main'),
785         ]
786
787     if xmlfiles is None:
788         xmlfiles = []
789         for res_dir in res_dirs:
790             for r, d, f in os.walk(res_dir):
791                 if r.endswith('/values'):
792                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
793
794     string_search = None
795     if string.startswith('@string/'):
796         string_search = re.compile(r'.*name="' + string[8:] + '".*?>([^<]+?)<.*').search
797     elif string.startswith('&') and string.endswith(';'):
798         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
799
800     if string_search is not None:
801         for xmlfile in xmlfiles:
802             for line in file(xmlfile):
803                 matches = string_search(line)
804                 if matches:
805                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
806         return None
807
808     return string.replace("\\'", "'")
809
810
811 # Return list of existing files that will be used to find the highest vercode
812 def manifest_paths(app_dir, flavour):
813
814     possible_manifests = \
815         [os.path.join(app_dir, 'AndroidManifest.xml'),
816          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
817          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
818          os.path.join(app_dir, 'build.gradle')]
819
820     if flavour:
821         possible_manifests.append(
822             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
823
824     return [path for path in possible_manifests if os.path.isfile(path)]
825
826
827 # Retrieve the package name. Returns the name, or None if not found.
828 def fetch_real_name(app_dir, flavour):
829     app_search = re.compile(r'.*<application.*').search
830     name_search = re.compile(r'.*android:label="([^"]+)".*').search
831     app_found = False
832     for f in manifest_paths(app_dir, flavour):
833         if not has_extension(f, 'xml'):
834             continue
835         logging.debug("fetch_real_name: Checking manifest at " + f)
836         for line in file(f):
837             if not app_found:
838                 if app_search(line):
839                     app_found = True
840             if app_found:
841                 matches = name_search(line)
842                 if matches:
843                     stringname = matches.group(1)
844                     logging.debug("fetch_real_name: using string " + stringname)
845                     result = retrieve_string(app_dir, stringname)
846                     if result:
847                         result = result.strip()
848                     return result
849     return None
850
851
852 # Retrieve the version name
853 def version_name(original, app_dir, flavour):
854     for f in manifest_paths(app_dir, flavour):
855         if not has_extension(f, 'xml'):
856             continue
857         string = retrieve_string(app_dir, original)
858         if string:
859             return string
860     return original
861
862
863 def get_library_references(root_dir):
864     libraries = []
865     proppath = os.path.join(root_dir, 'project.properties')
866     if not os.path.isfile(proppath):
867         return libraries
868     with open(proppath) as f:
869         for line in f.readlines():
870             if not line.startswith('android.library.reference.'):
871                 continue
872             path = line.split('=')[1].strip()
873             relpath = os.path.join(root_dir, path)
874             if not os.path.isdir(relpath):
875                 continue
876             logging.debug("Found subproject at %s" % path)
877             libraries.append(path)
878     return libraries
879
880
881 def ant_subprojects(root_dir):
882     subprojects = get_library_references(root_dir)
883     for subpath in subprojects:
884         subrelpath = os.path.join(root_dir, subpath)
885         for p in get_library_references(subrelpath):
886             relp = os.path.normpath(os.path.join(subpath, p))
887             if relp not in subprojects:
888                 subprojects.insert(0, relp)
889     return subprojects
890
891
892 def remove_debuggable_flags(root_dir):
893     # Remove forced debuggable flags
894     logging.info("Removing debuggable flags from %s" % root_dir)
895     for root, dirs, files in os.walk(root_dir):
896         if 'AndroidManifest.xml' in files:
897             path = os.path.join(root, 'AndroidManifest.xml')
898             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path])
899             if p.returncode != 0:
900                 raise BuildException("Failed to remove debuggable flags of %s" % path)
901
902
903 # Extract some information from the AndroidManifest.xml at the given path.
904 # Returns (version, vercode, package), any or all of which might be None.
905 # All values returned are strings.
906 def parse_androidmanifests(paths, ignoreversions=None):
907
908     if not paths:
909         return (None, None, None)
910
911     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
912     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
913     psearch = re.compile(r'.*package="([^"]+)".*').search
914
915     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
916     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
917     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
918
919     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
920
921     max_version = None
922     max_vercode = None
923     max_package = None
924
925     for path in paths:
926
927         gradle = has_extension(path, 'gradle')
928         version = None
929         vercode = None
930         # Remember package name, may be defined separately from version+vercode
931         package = max_package
932
933         for line in file(path):
934             if not package:
935                 if gradle:
936                     matches = psearch_g(line)
937                 else:
938                     matches = psearch(line)
939                 if matches:
940                     package = matches.group(1)
941             if not version:
942                 if gradle:
943                     matches = vnsearch_g(line)
944                 else:
945                     matches = vnsearch(line)
946                 if matches:
947                     version = matches.group(2 if gradle else 1)
948             if not vercode:
949                 if gradle:
950                     matches = vcsearch_g(line)
951                 else:
952                     matches = vcsearch(line)
953                 if matches:
954                     vercode = matches.group(1)
955
956         # Always grab the package name and version name in case they are not
957         # together with the highest version code
958         if max_package is None and package is not None:
959             max_package = package
960         if max_version is None and version is not None:
961             max_version = version
962
963         if max_vercode is None or (vercode is not None and vercode > max_vercode):
964             if not ignoresearch or not ignoresearch(version):
965                 if version is not None:
966                     max_version = version
967                 if vercode is not None:
968                     max_vercode = vercode
969                 if package is not None:
970                     max_package = package
971             else:
972                 max_version = "Ignore"
973
974     if max_version is None:
975         max_version = "Unknown"
976
977     return (max_version, max_vercode, max_package)
978
979
980 class FDroidException(Exception):
981     def __init__(self, value, detail=None):
982         self.value = value
983         self.detail = detail
984
985     def get_wikitext(self):
986         ret = repr(self.value) + "\n"
987         if self.detail:
988             ret += "=detail=\n"
989             ret += "<pre>\n"
990             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
991             ret += str(txt)
992             ret += "</pre>\n"
993         return ret
994
995     def __str__(self):
996         ret = self.value
997         if self.detail:
998             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
999         return ret
1000
1001
1002 class VCSException(FDroidException):
1003     pass
1004
1005
1006 class BuildException(FDroidException):
1007     pass
1008
1009
1010 # Get the specified source library.
1011 # Returns the path to it. Normally this is the path to be used when referencing
1012 # it, which may be a subdirectory of the actual project. If you want the base
1013 # directory of the project, pass 'basepath=True'.
1014 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1015               basepath=False, raw=False, prepare=True, preponly=False):
1016
1017     number = None
1018     subdir = None
1019     if raw:
1020         name = spec
1021         ref = None
1022     else:
1023         name, ref = spec.split('@')
1024         if ':' in name:
1025             number, name = name.split(':', 1)
1026         if '/' in name:
1027             name, subdir = name.split('/', 1)
1028
1029     if name not in metadata.srclibs:
1030         raise VCSException('srclib ' + name + ' not found.')
1031
1032     srclib = metadata.srclibs[name]
1033
1034     sdir = os.path.join(srclib_dir, name)
1035
1036     if not preponly:
1037         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1038         vcs.srclib = (name, number, sdir)
1039         if ref:
1040             vcs.gotorevision(ref)
1041
1042         if raw:
1043             return vcs
1044
1045     libdir = None
1046     if subdir:
1047         libdir = os.path.join(sdir, subdir)
1048     elif srclib["Subdir"]:
1049         for subdir in srclib["Subdir"]:
1050             libdir_candidate = os.path.join(sdir, subdir)
1051             if os.path.exists(libdir_candidate):
1052                 libdir = libdir_candidate
1053                 break
1054
1055     if libdir is None:
1056         libdir = sdir
1057
1058     if srclib["Srclibs"]:
1059         n = 1
1060         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1061             s_tuple = None
1062             for t in srclibpaths:
1063                 if t[0] == lib:
1064                     s_tuple = t
1065                     break
1066             if s_tuple is None:
1067                 raise VCSException('Missing recursive srclib %s for %s' % (
1068                     lib, name))
1069             place_srclib(libdir, n, s_tuple[2])
1070             n += 1
1071
1072     remove_signing_keys(sdir)
1073     remove_debuggable_flags(sdir)
1074
1075     if prepare:
1076
1077         if srclib["Prepare"]:
1078             cmd = replace_config_vars(srclib["Prepare"])
1079
1080             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1081             if p.returncode != 0:
1082                 raise BuildException("Error running prepare command for srclib %s"
1083                                      % name, p.output)
1084
1085     if basepath:
1086         libdir = sdir
1087
1088     return (name, number, libdir)
1089
1090
1091 # Prepare the source code for a particular build
1092 #  'vcs'         - the appropriate vcs object for the application
1093 #  'app'         - the application details from the metadata
1094 #  'build'       - the build details from the metadata
1095 #  'build_dir'   - the path to the build directory, usually
1096 #                   'build/app.id'
1097 #  'srclib_dir'  - the path to the source libraries directory, usually
1098 #                   'build/srclib'
1099 #  'extlib_dir'  - the path to the external libraries directory, usually
1100 #                   'build/extlib'
1101 # Returns the (root, srclibpaths) where:
1102 #   'root' is the root directory, which may be the same as 'build_dir' or may
1103 #          be a subdirectory of it.
1104 #   'srclibpaths' is information on the srclibs being used
1105 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1106
1107     # Optionally, the actual app source can be in a subdirectory
1108     if build['subdir']:
1109         root_dir = os.path.join(build_dir, build['subdir'])
1110     else:
1111         root_dir = build_dir
1112
1113     # Get a working copy of the right revision
1114     logging.info("Getting source for revision " + build['commit'])
1115     vcs.gotorevision(build['commit'])
1116
1117     # Initialise submodules if requred
1118     if build['submodules']:
1119         logging.info("Initialising submodules")
1120         vcs.initsubmodules()
1121
1122     # Check that a subdir (if we're using one) exists. This has to happen
1123     # after the checkout, since it might not exist elsewhere
1124     if not os.path.exists(root_dir):
1125         raise BuildException('Missing subdir ' + root_dir)
1126
1127     # Run an init command if one is required
1128     if build['init']:
1129         cmd = replace_config_vars(build['init'])
1130         logging.info("Running 'init' commands in %s" % root_dir)
1131
1132         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1133         if p.returncode != 0:
1134             raise BuildException("Error running init command for %s:%s" %
1135                                  (app['id'], build['version']), p.output)
1136
1137     # Apply patches if any
1138     if build['patch']:
1139         logging.info("Applying patches")
1140         for patch in build['patch']:
1141             patch = patch.strip()
1142             logging.info("Applying " + patch)
1143             patch_path = os.path.join('metadata', app['id'], patch)
1144             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1145             if p.returncode != 0:
1146                 raise BuildException("Failed to apply patch %s" % patch_path)
1147
1148     # Get required source libraries
1149     srclibpaths = []
1150     if build['srclibs']:
1151         logging.info("Collecting source libraries")
1152         for lib in build['srclibs']:
1153             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1154                                          preponly=onserver))
1155
1156     for name, number, libpath in srclibpaths:
1157         place_srclib(root_dir, int(number) if number else None, libpath)
1158
1159     basesrclib = vcs.getsrclib()
1160     # If one was used for the main source, add that too.
1161     if basesrclib:
1162         srclibpaths.append(basesrclib)
1163
1164     # Update the local.properties file
1165     localprops = [os.path.join(build_dir, 'local.properties')]
1166     if build['subdir']:
1167         localprops += [os.path.join(root_dir, 'local.properties')]
1168     for path in localprops:
1169         if not os.path.isfile(path):
1170             continue
1171         logging.info("Updating properties file at %s" % path)
1172         f = open(path, 'r')
1173         props = f.read()
1174         f.close()
1175         props += '\n'
1176         # Fix old-fashioned 'sdk-location' by copying
1177         # from sdk.dir, if necessary
1178         if build['oldsdkloc']:
1179             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1180                               re.S | re.M).group(1)
1181             props += "sdk-location=%s\n" % sdkloc
1182         else:
1183             props += "sdk.dir=%s\n" % config['sdk_path']
1184             props += "sdk-location=%s\n" % config['sdk_path']
1185         if 'ndk_path' in config:
1186             # Add ndk location
1187             props += "ndk.dir=%s\n" % config['ndk_path']
1188             props += "ndk-location=%s\n" % config['ndk_path']
1189         # Add java.encoding if necessary
1190         if build['encoding']:
1191             props += "java.encoding=%s\n" % build['encoding']
1192         f = open(path, 'w')
1193         f.write(props)
1194         f.close()
1195
1196     flavour = None
1197     if build['type'] == 'gradle':
1198         flavour = build['gradle'].split('@')[0]
1199         if flavour in ['main', 'yes', '']:
1200             flavour = None
1201
1202         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1203         gradlepluginver = None
1204
1205         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1206
1207         # Parent dir build.gradle
1208         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1209         if parent_dir.startswith(build_dir):
1210             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1211
1212         # Gradle execution dir build.gradle
1213         if '@' in build['gradle']:
1214             gradle_file = os.path.join(root_dir, build['gradle'].split('@', 1)[1], 'build.gradle')
1215             gradle_file = os.path.normpath(gradle_file)
1216             if gradle_file not in gradle_files:
1217                 gradle_files.append(gradle_file)
1218
1219         for path in gradle_files:
1220             if gradlepluginver:
1221                 break
1222             if not os.path.isfile(path):
1223                 continue
1224             with open(path) as f:
1225                 for line in f:
1226                     match = version_regex.match(line)
1227                     if match:
1228                         gradlepluginver = match.group(1)
1229                         break
1230
1231         if gradlepluginver:
1232             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1233         else:
1234             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1235             build['gradlepluginver'] = LooseVersion('0.11')
1236
1237         if build['target']:
1238             n = build["target"].split('-')[1]
1239             FDroidPopen(['sed', '-i',
1240                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1241                          'build.gradle'],
1242                         cwd=root_dir)
1243
1244     # Remove forced debuggable flags
1245     remove_debuggable_flags(root_dir)
1246
1247     # Insert version code and number into the manifest if necessary
1248     if build['forceversion']:
1249         logging.info("Changing the version name")
1250         for path in manifest_paths(root_dir, flavour):
1251             if not os.path.isfile(path):
1252                 continue
1253             if has_extension(path, 'xml'):
1254                 p = SilentPopen(['sed', '-i',
1255                                  's/android:versionName="[^"]*"/android:versionName="'
1256                                  + build['version'] + '"/g',
1257                                  path])
1258                 if p.returncode != 0:
1259                     raise BuildException("Failed to amend manifest")
1260             elif has_extension(path, 'gradle'):
1261                 p = SilentPopen(['sed', '-i',
1262                                  's/versionName *=* *"[^"]*"/versionName = "'
1263                                  + build['version'] + '"/g',
1264                                  path])
1265                 if p.returncode != 0:
1266                     raise BuildException("Failed to amend build.gradle")
1267     if build['forcevercode']:
1268         logging.info("Changing the version code")
1269         for path in manifest_paths(root_dir, flavour):
1270             if not os.path.isfile(path):
1271                 continue
1272             if has_extension(path, 'xml'):
1273                 p = SilentPopen(['sed', '-i',
1274                                  's/android:versionCode="[^"]*"/android:versionCode="'
1275                                  + build['vercode'] + '"/g',
1276                                  path])
1277                 if p.returncode != 0:
1278                     raise BuildException("Failed to amend manifest")
1279             elif has_extension(path, 'gradle'):
1280                 p = SilentPopen(['sed', '-i',
1281                                  's/versionCode *=* *[0-9]*/versionCode = '
1282                                  + build['vercode'] + '/g',
1283                                  path])
1284                 if p.returncode != 0:
1285                     raise BuildException("Failed to amend build.gradle")
1286
1287     # Delete unwanted files
1288     if build['rm']:
1289         logging.info("Removing specified files")
1290         for part in getpaths(build_dir, build, 'rm'):
1291             dest = os.path.join(build_dir, part)
1292             logging.info("Removing {0}".format(part))
1293             if os.path.lexists(dest):
1294                 if os.path.islink(dest):
1295                     SilentPopen(['unlink ' + dest], shell=True)
1296                 else:
1297                     SilentPopen(['rm -rf ' + dest], shell=True)
1298             else:
1299                 logging.info("...but it didn't exist")
1300
1301     remove_signing_keys(build_dir)
1302
1303     # Add required external libraries
1304     if build['extlibs']:
1305         logging.info("Collecting prebuilt libraries")
1306         libsdir = os.path.join(root_dir, 'libs')
1307         if not os.path.exists(libsdir):
1308             os.mkdir(libsdir)
1309         for lib in build['extlibs']:
1310             lib = lib.strip()
1311             logging.info("...installing extlib {0}".format(lib))
1312             libf = os.path.basename(lib)
1313             libsrc = os.path.join(extlib_dir, lib)
1314             if not os.path.exists(libsrc):
1315                 raise BuildException("Missing extlib file {0}".format(libsrc))
1316             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1317
1318     # Run a pre-build command if one is required
1319     if build['prebuild']:
1320         logging.info("Running 'prebuild' commands in %s" % root_dir)
1321
1322         cmd = replace_config_vars(build['prebuild'])
1323
1324         # Substitute source library paths into prebuild commands
1325         for name, number, libpath in srclibpaths:
1326             libpath = os.path.relpath(libpath, root_dir)
1327             cmd = cmd.replace('$$' + name + '$$', libpath)
1328
1329         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1330         if p.returncode != 0:
1331             raise BuildException("Error running prebuild command for %s:%s" %
1332                                  (app['id'], build['version']), p.output)
1333
1334     # Generate (or update) the ant build file, build.xml...
1335     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1336         parms = [config['android'], 'update', 'lib-project']
1337         lparms = [config['android'], 'update', 'project']
1338
1339         if build['target']:
1340             parms += ['-t', build['target']]
1341             lparms += ['-t', build['target']]
1342         if build['update'] == ['auto']:
1343             update_dirs = ant_subprojects(root_dir) + ['.']
1344         else:
1345             update_dirs = build['update']
1346
1347         for d in update_dirs:
1348             subdir = os.path.join(root_dir, d)
1349             if d == '.':
1350                 logging.info("Updating main project")
1351                 cmd = parms + ['-p', d]
1352             else:
1353                 logging.info("Updating subproject %s" % d)
1354                 cmd = lparms + ['-p', d]
1355             p = FDroidPopen(cmd, cwd=root_dir)
1356             # Check to see whether an error was returned without a proper exit
1357             # code (this is the case for the 'no target set or target invalid'
1358             # error)
1359             if p.returncode != 0 or p.output.startswith("Error: "):
1360                 raise BuildException("Failed to update project at %s" % d, p.output)
1361             # Clean update dirs via ant
1362             if d != '.':
1363                 logging.info("Cleaning subproject %s" % d)
1364                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1365
1366     return (root_dir, srclibpaths)
1367
1368
1369 # Split and extend via globbing the paths from a field
1370 def getpaths(build_dir, build, field):
1371     paths = []
1372     for p in build[field]:
1373         p = p.strip()
1374         full_path = os.path.join(build_dir, p)
1375         full_path = os.path.normpath(full_path)
1376         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1377     return paths
1378
1379
1380 # Scan the source code in the given directory (and all subdirectories)
1381 # and return the number of fatal problems encountered
1382 def scan_source(build_dir, root_dir, thisbuild):
1383
1384     count = 0
1385
1386     # Common known non-free blobs (always lower case):
1387     usual_suspects = [
1388         re.compile(r'flurryagent', re.IGNORECASE),
1389         re.compile(r'paypal.*mpl', re.IGNORECASE),
1390         re.compile(r'libgoogleanalytics', re.IGNORECASE),
1391         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1392         re.compile(r'googleadview', re.IGNORECASE),
1393         re.compile(r'googleadmobadssdk', re.IGNORECASE),
1394         re.compile(r'google.*play.*services', re.IGNORECASE),
1395         re.compile(r'crittercism', re.IGNORECASE),
1396         re.compile(r'heyzap', re.IGNORECASE),
1397         re.compile(r'jpct.*ae', re.IGNORECASE),
1398         re.compile(r'youtubeandroidplayerapi', re.IGNORECASE),
1399         re.compile(r'bugsense', re.IGNORECASE),
1400         re.compile(r'crashlytics', re.IGNORECASE),
1401         re.compile(r'ouya.*sdk', re.IGNORECASE),
1402         re.compile(r'libspen23', re.IGNORECASE),
1403         ]
1404
1405     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1406     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1407
1408     try:
1409         ms = magic.open(magic.MIME_TYPE)
1410         ms.load()
1411     except AttributeError:
1412         ms = None
1413
1414     def toignore(fd):
1415         for i in scanignore:
1416             if fd.startswith(i):
1417                 return True
1418         return False
1419
1420     def todelete(fd):
1421         for i in scandelete:
1422             if fd.startswith(i):
1423                 return True
1424         return False
1425
1426     def removeproblem(what, fd, fp):
1427         logging.info('Removing %s at %s' % (what, fd))
1428         os.remove(fp)
1429
1430     def warnproblem(what, fd):
1431         logging.warn('Found %s at %s' % (what, fd))
1432
1433     def handleproblem(what, fd, fp):
1434         if todelete(fd):
1435             removeproblem(what, fd, fp)
1436         else:
1437             logging.error('Found %s at %s' % (what, fd))
1438             return True
1439         return False
1440
1441     def insidedir(path, dirname):
1442         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1443
1444     # Iterate through all files in the source code
1445     for r, d, f in os.walk(build_dir):
1446
1447         if any(insidedir(r, d) for d in ('.hg', '.git', '.svn', '.bzr')):
1448             continue
1449
1450         for curfile in f:
1451
1452             # Path (relative) to the file
1453             fp = os.path.join(r, curfile)
1454             fd = fp[len(build_dir) + 1:]
1455
1456             # Check if this file has been explicitly excluded from scanning
1457             if toignore(fd):
1458                 continue
1459
1460             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1461
1462             if mime == 'application/x-sharedlib':
1463                 count += handleproblem('shared library', fd, fp)
1464
1465             elif mime == 'application/x-archive':
1466                 count += handleproblem('static library', fd, fp)
1467
1468             elif mime == 'application/x-executable':
1469                 count += handleproblem('binary executable', fd, fp)
1470
1471             elif mime == 'application/x-java-applet':
1472                 count += handleproblem('Java compiled class', fd, fp)
1473
1474             elif mime in (
1475                     'application/jar',
1476                     'application/zip',
1477                     'application/java-archive',
1478                     'application/octet-stream',
1479                     'binary',
1480                     ):
1481
1482                 if has_extension(fp, 'apk'):
1483                     removeproblem('APK file', fd, fp)
1484
1485                 elif has_extension(fp, 'jar'):
1486
1487                     if any(suspect.match(curfile) for suspect in usual_suspects):
1488                         count += handleproblem('usual supect', fd, fp)
1489                     else:
1490                         warnproblem('JAR file', fd)
1491
1492                 elif has_extension(fp, 'zip'):
1493                     warnproblem('ZIP file', fd)
1494
1495                 else:
1496                     warnproblem('unknown compressed or binary file', fd)
1497
1498             elif has_extension(fp, 'java'):
1499                 for line in file(fp):
1500                     if 'DexClassLoader' in line:
1501                         count += handleproblem('DexClassLoader', fd, fp)
1502                         break
1503     if ms is not None:
1504         ms.close()
1505
1506     # Presence of a jni directory without buildjni=yes might
1507     # indicate a problem (if it's not a problem, explicitly use
1508     # buildjni=no to bypass this check)
1509     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1510             not thisbuild['buildjni']):
1511         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1512         count += 1
1513
1514     return count
1515
1516
1517 class KnownApks:
1518
1519     def __init__(self):
1520         self.path = os.path.join('stats', 'known_apks.txt')
1521         self.apks = {}
1522         if os.path.exists(self.path):
1523             for line in file(self.path):
1524                 t = line.rstrip().split(' ')
1525                 if len(t) == 2:
1526                     self.apks[t[0]] = (t[1], None)
1527                 else:
1528                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1529         self.changed = False
1530
1531     def writeifchanged(self):
1532         if self.changed:
1533             if not os.path.exists('stats'):
1534                 os.mkdir('stats')
1535             f = open(self.path, 'w')
1536             lst = []
1537             for apk, app in self.apks.iteritems():
1538                 appid, added = app
1539                 line = apk + ' ' + appid
1540                 if added:
1541                     line += ' ' + time.strftime('%Y-%m-%d', added)
1542                 lst.append(line)
1543             for line in sorted(lst):
1544                 f.write(line + '\n')
1545             f.close()
1546
1547     # Record an apk (if it's new, otherwise does nothing)
1548     # Returns the date it was added.
1549     def recordapk(self, apk, app):
1550         if apk not in self.apks:
1551             self.apks[apk] = (app, time.gmtime(time.time()))
1552             self.changed = True
1553         _, added = self.apks[apk]
1554         return added
1555
1556     # Look up information - given the 'apkname', returns (app id, date added/None).
1557     # Or returns None for an unknown apk.
1558     def getapp(self, apkname):
1559         if apkname in self.apks:
1560             return self.apks[apkname]
1561         return None
1562
1563     # Get the most recent 'num' apps added to the repo, as a list of package ids
1564     # with the most recent first.
1565     def getlatest(self, num):
1566         apps = {}
1567         for apk, app in self.apks.iteritems():
1568             appid, added = app
1569             if added:
1570                 if appid in apps:
1571                     if apps[appid] > added:
1572                         apps[appid] = added
1573                 else:
1574                     apps[appid] = added
1575         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1576         lst = [app for app, _ in sortedapps]
1577         lst.reverse()
1578         return lst
1579
1580
1581 def isApkDebuggable(apkfile, config):
1582     """Returns True if the given apk file is debuggable
1583
1584     :param apkfile: full path to the apk to check"""
1585
1586     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1587                                   config['build_tools'], 'aapt'),
1588                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1589     if p.returncode != 0:
1590         logging.critical("Failed to get apk manifest information")
1591         sys.exit(1)
1592     for line in p.output.splitlines():
1593         if 'android:debuggable' in line and not line.endswith('0x0'):
1594             return True
1595     return False
1596
1597
1598 class AsynchronousFileReader(threading.Thread):
1599     '''
1600     Helper class to implement asynchronous reading of a file
1601     in a separate thread. Pushes read lines on a queue to
1602     be consumed in another thread.
1603     '''
1604
1605     def __init__(self, fd, queue):
1606         assert isinstance(queue, Queue.Queue)
1607         assert callable(fd.readline)
1608         threading.Thread.__init__(self)
1609         self._fd = fd
1610         self._queue = queue
1611
1612     def run(self):
1613         '''The body of the tread: read lines and put them on the queue.'''
1614         for line in iter(self._fd.readline, ''):
1615             self._queue.put(line)
1616
1617     def eof(self):
1618         '''Check whether there is no more content to expect.'''
1619         return not self.is_alive() and self._queue.empty()
1620
1621
1622 class PopenResult:
1623     returncode = None
1624     output = ''
1625
1626
1627 def SilentPopen(commands, cwd=None, shell=False):
1628     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1629
1630
1631 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1632     """
1633     Run a command and capture the possibly huge output.
1634
1635     :param commands: command and argument list like in subprocess.Popen
1636     :param cwd: optionally specifies a working directory
1637     :returns: A PopenResult.
1638     """
1639
1640     global env
1641
1642     if cwd:
1643         cwd = os.path.normpath(cwd)
1644         logging.debug("Directory: %s" % cwd)
1645     logging.debug("> %s" % ' '.join(commands))
1646
1647     result = PopenResult()
1648     p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1649                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1650
1651     stdout_queue = Queue.Queue()
1652     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1653     stdout_reader.start()
1654
1655     # Check the queue for output (until there is no more to get)
1656     while not stdout_reader.eof():
1657         while not stdout_queue.empty():
1658             line = stdout_queue.get()
1659             if output:
1660                 # Output directly to console
1661                 sys.stdout.write(line)
1662                 sys.stdout.flush()
1663             result.output += line
1664
1665         time.sleep(0.1)
1666
1667     p.communicate()
1668     result.returncode = p.returncode
1669     return result
1670
1671
1672 def remove_signing_keys(build_dir):
1673     comment = re.compile(r'[ ]*//')
1674     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1675     line_matches = [
1676         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1677         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1678         re.compile(r'.*variant\.outputFile = .*'),
1679         re.compile(r'.*\.readLine\(.*'),
1680         ]
1681     for root, dirs, files in os.walk(build_dir):
1682         if 'build.gradle' in files:
1683             path = os.path.join(root, 'build.gradle')
1684
1685             with open(path, "r") as o:
1686                 lines = o.readlines()
1687
1688             changed = False
1689
1690             opened = 0
1691             with open(path, "w") as o:
1692                 for line in lines:
1693                     if comment.match(line):
1694                         continue
1695
1696                     if opened > 0:
1697                         opened += line.count('{')
1698                         opened -= line.count('}')
1699                         continue
1700
1701                     if signing_configs.match(line):
1702                         changed = True
1703                         opened += 1
1704                         continue
1705
1706                     if any(s.match(line) for s in line_matches):
1707                         changed = True
1708                         continue
1709
1710                     if opened == 0:
1711                         o.write(line)
1712
1713             if changed:
1714                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1715
1716         for propfile in [
1717                 'project.properties',
1718                 'build.properties',
1719                 'default.properties',
1720                 'ant.properties',
1721                 ]:
1722             if propfile in files:
1723                 path = os.path.join(root, propfile)
1724
1725                 with open(path, "r") as o:
1726                     lines = o.readlines()
1727
1728                 changed = False
1729
1730                 with open(path, "w") as o:
1731                     for line in lines:
1732                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1733                             changed = True
1734                             continue
1735
1736                         o.write(line)
1737
1738                 if changed:
1739                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1740
1741
1742 def replace_config_vars(cmd):
1743     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1744     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1745     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1746     return cmd
1747
1748
1749 def place_srclib(root_dir, number, libpath):
1750     if not number:
1751         return
1752     relpath = os.path.relpath(libpath, root_dir)
1753     proppath = os.path.join(root_dir, 'project.properties')
1754
1755     lines = []
1756     if os.path.isfile(proppath):
1757         with open(proppath, "r") as o:
1758             lines = o.readlines()
1759
1760     with open(proppath, "w") as o:
1761         placed = False
1762         for line in lines:
1763             if line.startswith('android.library.reference.%d=' % number):
1764                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1765                 placed = True
1766             else:
1767                 o.write(line)
1768         if not placed:
1769             o.write('android.library.reference.%d=%s\n' % (number, relpath))