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