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