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