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