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