chiark / gitweb /
a20aa59b478822ced8a95353ea62bc29fe4e9d55
[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             try:
1076                 xml = parse_xml(path)
1077                 if "package" in xml.attrib:
1078                     s = xml.attrib["package"].encode('utf-8')
1079                     if app_matches_packagename(app, s):
1080                         package = s
1081                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1082                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1083                     base_dir = os.path.dirname(path)
1084                     version = retrieve_string_singleline(base_dir, version)
1085                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1086                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1087                     if string_is_integer(a):
1088                         vercode = a
1089             except Exception:
1090                 logging.warning("Problem with xml at {0}".format(path))
1091
1092         # Remember package name, may be defined separately from version+vercode
1093         if package is None:
1094             package = max_package
1095
1096         logging.debug("..got package={0}, version={1}, vercode={2}"
1097                       .format(package, version, vercode))
1098
1099         # Always grab the package name and version name in case they are not
1100         # together with the highest version code
1101         if max_package is None and package is not None:
1102             max_package = package
1103         if max_version is None and version is not None:
1104             max_version = version
1105
1106         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1107             if not ignoresearch or not ignoresearch(version):
1108                 if version is not None:
1109                     max_version = version
1110                 if vercode is not None:
1111                     max_vercode = vercode
1112                 if package is not None:
1113                     max_package = package
1114             else:
1115                 max_version = "Ignore"
1116
1117     if max_version is None:
1118         max_version = "Unknown"
1119
1120     if max_package and not is_valid_package_name(max_package):
1121         raise FDroidException("Invalid package name {0}".format(max_package))
1122
1123     return (max_version, max_vercode, max_package)
1124
1125
1126 def is_valid_package_name(name):
1127     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1128
1129
1130 class FDroidException(Exception):
1131
1132     def __init__(self, value, detail=None):
1133         self.value = value
1134         self.detail = detail
1135
1136     def shortened_detail(self):
1137         if len(self.detail) < 16000:
1138             return self.detail
1139         return '[...]\n' + self.detail[-16000:]
1140
1141     def get_wikitext(self):
1142         ret = repr(self.value) + "\n"
1143         if self.detail:
1144             ret += "=detail=\n"
1145             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1146         return ret
1147
1148     def __str__(self):
1149         ret = self.value
1150         if self.detail:
1151             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1152         return ret
1153
1154
1155 class VCSException(FDroidException):
1156     pass
1157
1158
1159 class BuildException(FDroidException):
1160     pass
1161
1162
1163 # Get the specified source library.
1164 # Returns the path to it. Normally this is the path to be used when referencing
1165 # it, which may be a subdirectory of the actual project. If you want the base
1166 # directory of the project, pass 'basepath=True'.
1167 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1168               raw=False, prepare=True, preponly=False, refresh=True):
1169
1170     number = None
1171     subdir = None
1172     if raw:
1173         name = spec
1174         ref = None
1175     else:
1176         name, ref = spec.split('@')
1177         if ':' in name:
1178             number, name = name.split(':', 1)
1179         if '/' in name:
1180             name, subdir = name.split('/', 1)
1181
1182     if name not in metadata.srclibs:
1183         raise VCSException('srclib ' + name + ' not found.')
1184
1185     srclib = metadata.srclibs[name]
1186
1187     sdir = os.path.join(srclib_dir, name)
1188
1189     if not preponly:
1190         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1191         vcs.srclib = (name, number, sdir)
1192         if ref:
1193             vcs.gotorevision(ref, refresh)
1194
1195         if raw:
1196             return vcs
1197
1198     libdir = None
1199     if subdir:
1200         libdir = os.path.join(sdir, subdir)
1201     elif srclib["Subdir"]:
1202         for subdir in srclib["Subdir"]:
1203             libdir_candidate = os.path.join(sdir, subdir)
1204             if os.path.exists(libdir_candidate):
1205                 libdir = libdir_candidate
1206                 break
1207
1208     if libdir is None:
1209         libdir = sdir
1210
1211     remove_signing_keys(sdir)
1212     remove_debuggable_flags(sdir)
1213
1214     if prepare:
1215
1216         if srclib["Prepare"]:
1217             cmd = replace_config_vars(srclib["Prepare"], None)
1218
1219             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1220             if p.returncode != 0:
1221                 raise BuildException("Error running prepare command for srclib %s"
1222                                      % name, p.output)
1223
1224     if basepath:
1225         libdir = sdir
1226
1227     return (name, number, libdir)
1228
1229 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1230
1231
1232 # Prepare the source code for a particular build
1233 #  'vcs'         - the appropriate vcs object for the application
1234 #  'app'         - the application details from the metadata
1235 #  'build'       - the build details from the metadata
1236 #  'build_dir'   - the path to the build directory, usually
1237 #                   'build/app.id'
1238 #  'srclib_dir'  - the path to the source libraries directory, usually
1239 #                   'build/srclib'
1240 #  'extlib_dir'  - the path to the external libraries directory, usually
1241 #                   'build/extlib'
1242 # Returns the (root, srclibpaths) where:
1243 #   'root' is the root directory, which may be the same as 'build_dir' or may
1244 #          be a subdirectory of it.
1245 #   'srclibpaths' is information on the srclibs being used
1246 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1247
1248     # Optionally, the actual app source can be in a subdirectory
1249     if build.subdir:
1250         root_dir = os.path.join(build_dir, build.subdir)
1251     else:
1252         root_dir = build_dir
1253
1254     # Get a working copy of the right revision
1255     logging.info("Getting source for revision " + build.commit)
1256     vcs.gotorevision(build.commit, refresh)
1257
1258     # Initialise submodules if required
1259     if build.submodules:
1260         logging.info("Initialising submodules")
1261         vcs.initsubmodules()
1262
1263     # Check that a subdir (if we're using one) exists. This has to happen
1264     # after the checkout, since it might not exist elsewhere
1265     if not os.path.exists(root_dir):
1266         raise BuildException('Missing subdir ' + root_dir)
1267
1268     # Run an init command if one is required
1269     if build.init:
1270         cmd = replace_config_vars(build.init, build)
1271         logging.info("Running 'init' commands in %s" % root_dir)
1272
1273         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1274         if p.returncode != 0:
1275             raise BuildException("Error running init command for %s:%s" %
1276                                  (app.id, build.version), p.output)
1277
1278     # Apply patches if any
1279     if build.patch:
1280         logging.info("Applying patches")
1281         for patch in build.patch:
1282             patch = patch.strip()
1283             logging.info("Applying " + patch)
1284             patch_path = os.path.join('metadata', app.id, patch)
1285             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1286             if p.returncode != 0:
1287                 raise BuildException("Failed to apply patch %s" % patch_path)
1288
1289     # Get required source libraries
1290     srclibpaths = []
1291     if build.srclibs:
1292         logging.info("Collecting source libraries")
1293         for lib in build.srclibs:
1294             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1295
1296     for name, number, libpath in srclibpaths:
1297         place_srclib(root_dir, int(number) if number else None, libpath)
1298
1299     basesrclib = vcs.getsrclib()
1300     # If one was used for the main source, add that too.
1301     if basesrclib:
1302         srclibpaths.append(basesrclib)
1303
1304     # Update the local.properties file
1305     localprops = [os.path.join(build_dir, 'local.properties')]
1306     if build.subdir:
1307         parts = build.subdir.split(os.sep)
1308         cur = build_dir
1309         for d in parts:
1310             cur = os.path.join(cur, d)
1311             localprops += [os.path.join(cur, 'local.properties')]
1312     for path in localprops:
1313         props = ""
1314         if os.path.isfile(path):
1315             logging.info("Updating local.properties file at %s" % path)
1316             with open(path, 'r') as f:
1317                 props += f.read()
1318             props += '\n'
1319         else:
1320             logging.info("Creating local.properties file at %s" % path)
1321         # Fix old-fashioned 'sdk-location' by copying
1322         # from sdk.dir, if necessary
1323         if build.oldsdkloc:
1324             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1325                               re.S | re.M).group(1)
1326             props += "sdk-location=%s\n" % sdkloc
1327         else:
1328             props += "sdk.dir=%s\n" % config['sdk_path']
1329             props += "sdk-location=%s\n" % config['sdk_path']
1330         ndk_path = build.ndk_path()
1331         if ndk_path:
1332             # Add ndk location
1333             props += "ndk.dir=%s\n" % ndk_path
1334             props += "ndk-location=%s\n" % ndk_path
1335         # Add java.encoding if necessary
1336         if build.encoding:
1337             props += "java.encoding=%s\n" % build.encoding
1338         with open(path, 'w') as f:
1339             f.write(props)
1340
1341     flavours = []
1342     if build.method() == 'gradle':
1343         flavours = build.gradle
1344
1345         if build.target:
1346             n = build.target.split('-')[1]
1347             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1348                         r'compileSdkVersion %s' % n,
1349                         os.path.join(root_dir, 'build.gradle'))
1350
1351     # Remove forced debuggable flags
1352     remove_debuggable_flags(root_dir)
1353
1354     # Insert version code and number into the manifest if necessary
1355     if build.forceversion:
1356         logging.info("Changing the version name")
1357         for path in manifest_paths(root_dir, flavours):
1358             if not os.path.isfile(path):
1359                 continue
1360             if has_extension(path, 'xml'):
1361                 regsub_file(r'android:versionName="[^"]*"',
1362                             r'android:versionName="%s"' % build.version,
1363                             path)
1364             elif has_extension(path, 'gradle'):
1365                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1366                             r"""\1versionName '%s'""" % build.version,
1367                             path)
1368
1369     if build.forcevercode:
1370         logging.info("Changing the version code")
1371         for path in manifest_paths(root_dir, flavours):
1372             if not os.path.isfile(path):
1373                 continue
1374             if has_extension(path, 'xml'):
1375                 regsub_file(r'android:versionCode="[^"]*"',
1376                             r'android:versionCode="%s"' % build.vercode,
1377                             path)
1378             elif has_extension(path, 'gradle'):
1379                 regsub_file(r'versionCode[ =]+[0-9]+',
1380                             r'versionCode %s' % build.vercode,
1381                             path)
1382
1383     # Delete unwanted files
1384     if build.rm:
1385         logging.info("Removing specified files")
1386         for part in getpaths(build_dir, build.rm):
1387             dest = os.path.join(build_dir, part)
1388             logging.info("Removing {0}".format(part))
1389             if os.path.lexists(dest):
1390                 if os.path.islink(dest):
1391                     FDroidPopen(['unlink', dest], output=False)
1392                 else:
1393                     FDroidPopen(['rm', '-rf', dest], output=False)
1394             else:
1395                 logging.info("...but it didn't exist")
1396
1397     remove_signing_keys(build_dir)
1398
1399     # Add required external libraries
1400     if build.extlibs:
1401         logging.info("Collecting prebuilt libraries")
1402         libsdir = os.path.join(root_dir, 'libs')
1403         if not os.path.exists(libsdir):
1404             os.mkdir(libsdir)
1405         for lib in build.extlibs:
1406             lib = lib.strip()
1407             logging.info("...installing extlib {0}".format(lib))
1408             libf = os.path.basename(lib)
1409             libsrc = os.path.join(extlib_dir, lib)
1410             if not os.path.exists(libsrc):
1411                 raise BuildException("Missing extlib file {0}".format(libsrc))
1412             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1413
1414     # Run a pre-build command if one is required
1415     if build.prebuild:
1416         logging.info("Running 'prebuild' commands in %s" % root_dir)
1417
1418         cmd = replace_config_vars(build.prebuild, build)
1419
1420         # Substitute source library paths into prebuild commands
1421         for name, number, libpath in srclibpaths:
1422             libpath = os.path.relpath(libpath, root_dir)
1423             cmd = cmd.replace('$$' + name + '$$', libpath)
1424
1425         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1426         if p.returncode != 0:
1427             raise BuildException("Error running prebuild command for %s:%s" %
1428                                  (app.id, build.version), p.output)
1429
1430     # Generate (or update) the ant build file, build.xml...
1431     if build.method() == 'ant' and build.update != ['no']:
1432         parms = ['android', 'update', 'lib-project']
1433         lparms = ['android', 'update', 'project']
1434
1435         if build.target:
1436             parms += ['-t', build.target]
1437             lparms += ['-t', build.target]
1438         if build.update:
1439             update_dirs = build.update
1440         else:
1441             update_dirs = ant_subprojects(root_dir) + ['.']
1442
1443         for d in update_dirs:
1444             subdir = os.path.join(root_dir, d)
1445             if d == '.':
1446                 logging.debug("Updating main project")
1447                 cmd = parms + ['-p', d]
1448             else:
1449                 logging.debug("Updating subproject %s" % d)
1450                 cmd = lparms + ['-p', d]
1451             p = SdkToolsPopen(cmd, cwd=root_dir)
1452             # Check to see whether an error was returned without a proper exit
1453             # code (this is the case for the 'no target set or target invalid'
1454             # error)
1455             if p.returncode != 0 or p.output.startswith("Error: "):
1456                 raise BuildException("Failed to update project at %s" % d, p.output)
1457             # Clean update dirs via ant
1458             if d != '.':
1459                 logging.info("Cleaning subproject %s" % d)
1460                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1461
1462     return (root_dir, srclibpaths)
1463
1464
1465 # Extend via globbing the paths from a field and return them as a map from
1466 # original path to resulting paths
1467 def getpaths_map(build_dir, globpaths):
1468     paths = dict()
1469     for p in globpaths:
1470         p = p.strip()
1471         full_path = os.path.join(build_dir, p)
1472         full_path = os.path.normpath(full_path)
1473         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1474         if not paths[p]:
1475             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1476     return paths
1477
1478
1479 # Extend via globbing the paths from a field and return them as a set
1480 def getpaths(build_dir, globpaths):
1481     paths_map = getpaths_map(build_dir, globpaths)
1482     paths = set()
1483     for k, v in paths_map.iteritems():
1484         for p in v:
1485             paths.add(p)
1486     return paths
1487
1488
1489 def natural_key(s):
1490     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1491
1492
1493 class KnownApks:
1494
1495     def __init__(self):
1496         self.path = os.path.join('stats', 'known_apks.txt')
1497         self.apks = {}
1498         if os.path.isfile(self.path):
1499             for line in file(self.path):
1500                 t = line.rstrip().split(' ')
1501                 if len(t) == 2:
1502                     self.apks[t[0]] = (t[1], None)
1503                 else:
1504                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1505         self.changed = False
1506
1507     def writeifchanged(self):
1508         if not self.changed:
1509             return
1510
1511         if not os.path.exists('stats'):
1512             os.mkdir('stats')
1513
1514         lst = []
1515         for apk, app in self.apks.iteritems():
1516             appid, added = app
1517             line = apk + ' ' + appid
1518             if added:
1519                 line += ' ' + time.strftime('%Y-%m-%d', added)
1520             lst.append(line)
1521
1522         with open(self.path, 'w') as f:
1523             for line in sorted(lst, key=natural_key):
1524                 f.write(line + '\n')
1525
1526     # Record an apk (if it's new, otherwise does nothing)
1527     # Returns the date it was added.
1528     def recordapk(self, apk, app):
1529         if apk not in self.apks:
1530             self.apks[apk] = (app, time.gmtime(time.time()))
1531             self.changed = True
1532         _, added = self.apks[apk]
1533         return added
1534
1535     # Look up information - given the 'apkname', returns (app id, date added/None).
1536     # Or returns None for an unknown apk.
1537     def getapp(self, apkname):
1538         if apkname in self.apks:
1539             return self.apks[apkname]
1540         return None
1541
1542     # Get the most recent 'num' apps added to the repo, as a list of package ids
1543     # with the most recent first.
1544     def getlatest(self, num):
1545         apps = {}
1546         for apk, app in self.apks.iteritems():
1547             appid, added = app
1548             if added:
1549                 if appid in apps:
1550                     if apps[appid] > added:
1551                         apps[appid] = added
1552                 else:
1553                     apps[appid] = added
1554         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1555         lst = [app for app, _ in sortedapps]
1556         lst.reverse()
1557         return lst
1558
1559
1560 def isApkDebuggable(apkfile, config):
1561     """Returns True if the given apk file is debuggable
1562
1563     :param apkfile: full path to the apk to check"""
1564
1565     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1566                       output=False)
1567     if p.returncode != 0:
1568         logging.critical("Failed to get apk manifest information")
1569         sys.exit(1)
1570     for line in p.output.splitlines():
1571         if 'android:debuggable' in line and not line.endswith('0x0'):
1572             return True
1573     return False
1574
1575
1576 class PopenResult:
1577     returncode = None
1578     output = ''
1579
1580
1581 def SdkToolsPopen(commands, cwd=None, output=True):
1582     cmd = commands[0]
1583     if cmd not in config:
1584         config[cmd] = find_sdk_tools_cmd(commands[0])
1585     abscmd = config[cmd]
1586     if abscmd is None:
1587         logging.critical("Could not find '%s' on your system" % cmd)
1588         sys.exit(1)
1589     return FDroidPopen([abscmd] + commands[1:],
1590                        cwd=cwd, output=output)
1591
1592
1593 def FDroidPopen(commands, cwd=None, output=True):
1594     """
1595     Run a command and capture the possibly huge output.
1596
1597     :param commands: command and argument list like in subprocess.Popen
1598     :param cwd: optionally specifies a working directory
1599     :returns: A PopenResult.
1600     """
1601
1602     global env
1603
1604     if cwd:
1605         cwd = os.path.normpath(cwd)
1606         logging.debug("Directory: %s" % cwd)
1607     logging.debug("> %s" % ' '.join(commands))
1608
1609     result = PopenResult()
1610     p = None
1611     try:
1612         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1613                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1614     except OSError as e:
1615         raise BuildException("OSError while trying to execute " +
1616                              ' '.join(commands) + ': ' + str(e))
1617
1618     stdout_queue = Queue()
1619     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1620
1621     # Check the queue for output (until there is no more to get)
1622     while not stdout_reader.eof():
1623         while not stdout_queue.empty():
1624             line = stdout_queue.get()
1625             if output and options.verbose:
1626                 # Output directly to console
1627                 sys.stderr.write(line)
1628                 sys.stderr.flush()
1629             result.output += line
1630
1631         time.sleep(0.1)
1632
1633     result.returncode = p.wait()
1634     return result
1635
1636
1637 gradle_comment = re.compile(r'[ ]*//')
1638 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1639 gradle_line_matches = [
1640     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1641     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1642     re.compile(r'.*\.readLine\(.*'),
1643 ]
1644
1645
1646 def remove_signing_keys(build_dir):
1647     for root, dirs, files in os.walk(build_dir):
1648         if 'build.gradle' in files:
1649             path = os.path.join(root, 'build.gradle')
1650
1651             with open(path, "r") as o:
1652                 lines = o.readlines()
1653
1654             changed = False
1655
1656             opened = 0
1657             i = 0
1658             with open(path, "w") as o:
1659                 while i < len(lines):
1660                     line = lines[i]
1661                     i += 1
1662                     while line.endswith('\\\n'):
1663                         line = line.rstrip('\\\n') + lines[i]
1664                         i += 1
1665
1666                     if gradle_comment.match(line):
1667                         o.write(line)
1668                         continue
1669
1670                     if opened > 0:
1671                         opened += line.count('{')
1672                         opened -= line.count('}')
1673                         continue
1674
1675                     if gradle_signing_configs.match(line):
1676                         changed = True
1677                         opened += 1
1678                         continue
1679
1680                     if any(s.match(line) for s in gradle_line_matches):
1681                         changed = True
1682                         continue
1683
1684                     if opened == 0:
1685                         o.write(line)
1686
1687             if changed:
1688                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1689
1690         for propfile in [
1691                 'project.properties',
1692                 'build.properties',
1693                 'default.properties',
1694                 'ant.properties', ]:
1695             if propfile in files:
1696                 path = os.path.join(root, propfile)
1697
1698                 with open(path, "r") as o:
1699                     lines = o.readlines()
1700
1701                 changed = False
1702
1703                 with open(path, "w") as o:
1704                     for line in lines:
1705                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1706                             changed = True
1707                             continue
1708
1709                         o.write(line)
1710
1711                 if changed:
1712                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1713
1714
1715 def reset_env_path():
1716     global env, orig_path
1717     env['PATH'] = orig_path
1718
1719
1720 def add_to_env_path(path):
1721     global env
1722     paths = env['PATH'].split(os.pathsep)
1723     if path in paths:
1724         return
1725     paths.append(path)
1726     env['PATH'] = os.pathsep.join(paths)
1727
1728
1729 def replace_config_vars(cmd, build):
1730     global env
1731     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1732     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1733     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1734     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1735     if build is not None:
1736         cmd = cmd.replace('$$COMMIT$$', build.commit)
1737         cmd = cmd.replace('$$VERSION$$', build.version)
1738         cmd = cmd.replace('$$VERCODE$$', build.vercode)
1739     return cmd
1740
1741
1742 def place_srclib(root_dir, number, libpath):
1743     if not number:
1744         return
1745     relpath = os.path.relpath(libpath, root_dir)
1746     proppath = os.path.join(root_dir, 'project.properties')
1747
1748     lines = []
1749     if os.path.isfile(proppath):
1750         with open(proppath, "r") as o:
1751             lines = o.readlines()
1752
1753     with open(proppath, "w") as o:
1754         placed = False
1755         for line in lines:
1756             if line.startswith('android.library.reference.%d=' % number):
1757                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1758                 placed = True
1759             else:
1760                 o.write(line)
1761         if not placed:
1762             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1763
1764 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1765
1766
1767 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1768     """Verify that two apks are the same
1769
1770     One of the inputs is signed, the other is unsigned. The signature metadata
1771     is transferred from the signed to the unsigned apk, and then jarsigner is
1772     used to verify that the signature from the signed apk is also varlid for
1773     the unsigned one.
1774     :param signed_apk: Path to a signed apk file
1775     :param unsigned_apk: Path to an unsigned apk file expected to match it
1776     :param tmp_dir: Path to directory for temporary files
1777     :returns: None if the verification is successful, otherwise a string
1778               describing what went wrong.
1779     """
1780     with ZipFile(signed_apk) as signed_apk_as_zip:
1781         meta_inf_files = ['META-INF/MANIFEST.MF']
1782         for f in signed_apk_as_zip.namelist():
1783             if apk_sigfile.match(f):
1784                 meta_inf_files.append(f)
1785         if len(meta_inf_files) < 3:
1786             return "Signature files missing from {0}".format(signed_apk)
1787         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1788     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1789         for meta_inf_file in meta_inf_files:
1790             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1791
1792     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1793         logging.info("...NOT verified - {0}".format(signed_apk))
1794         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1795     logging.info("...successfully verified")
1796     return None
1797
1798 apk_badchars = re.compile('''[/ :;'"]''')
1799
1800
1801 def compare_apks(apk1, apk2, tmp_dir):
1802     """Compare two apks
1803
1804     Returns None if the apk content is the same (apart from the signing key),
1805     otherwise a string describing what's different, or what went wrong when
1806     trying to do the comparison.
1807     """
1808
1809     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
1810     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
1811     for d in [apk1dir, apk2dir]:
1812         if os.path.exists(d):
1813             shutil.rmtree(d)
1814         os.mkdir(d)
1815         os.mkdir(os.path.join(d, 'jar-xf'))
1816
1817     if subprocess.call(['jar', 'xf',
1818                         os.path.abspath(apk1)],
1819                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1820         return("Failed to unpack " + apk1)
1821     if subprocess.call(['jar', 'xf',
1822                         os.path.abspath(apk2)],
1823                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1824         return("Failed to unpack " + apk2)
1825
1826     # try to find apktool in the path, if it hasn't been manually configed
1827     if 'apktool' not in config:
1828         tmp = find_command('apktool')
1829         if tmp is not None:
1830             config['apktool'] = tmp
1831     if 'apktool' in config:
1832         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1833                            cwd=apk1dir) != 0:
1834             return("Failed to unpack " + apk1)
1835         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1836                            cwd=apk2dir) != 0:
1837             return("Failed to unpack " + apk2)
1838
1839     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1840     lines = p.output.splitlines()
1841     if len(lines) != 1 or 'META-INF' not in lines[0]:
1842         meld = find_command('meld')
1843         if meld is not None:
1844             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1845         return("Unexpected diff output - " + p.output)
1846
1847     # since everything verifies, delete the comparison to keep cruft down
1848     shutil.rmtree(apk1dir)
1849     shutil.rmtree(apk2dir)
1850
1851     # If we get here, it seems like they're the same!
1852     return None
1853
1854
1855 def find_command(command):
1856     '''find the full path of a command, or None if it can't be found in the PATH'''
1857
1858     def is_exe(fpath):
1859         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1860
1861     fpath, fname = os.path.split(command)
1862     if fpath:
1863         if is_exe(command):
1864             return command
1865     else:
1866         for path in os.environ["PATH"].split(os.pathsep):
1867             path = path.strip('"')
1868             exe_file = os.path.join(path, command)
1869             if is_exe(exe_file):
1870                 return exe_file
1871
1872     return None
1873
1874
1875 def genpassword():
1876     '''generate a random password for when generating keys'''
1877     h = hashlib.sha256()
1878     h.update(os.urandom(16))  # salt
1879     h.update(bytes(socket.getfqdn()))
1880     return h.digest().encode('base64').strip()
1881
1882
1883 def genkeystore(localconfig):
1884     '''Generate a new key with random passwords and add it to new keystore'''
1885     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1886     keystoredir = os.path.dirname(localconfig['keystore'])
1887     if keystoredir is None or keystoredir == '':
1888         keystoredir = os.path.join(os.getcwd(), keystoredir)
1889     if not os.path.exists(keystoredir):
1890         os.makedirs(keystoredir, mode=0o700)
1891
1892     write_password_file("keystorepass", localconfig['keystorepass'])
1893     write_password_file("keypass", localconfig['keypass'])
1894     p = FDroidPopen(['keytool', '-genkey',
1895                      '-keystore', localconfig['keystore'],
1896                      '-alias', localconfig['repo_keyalias'],
1897                      '-keyalg', 'RSA', '-keysize', '4096',
1898                      '-sigalg', 'SHA256withRSA',
1899                      '-validity', '10000',
1900                      '-storepass:file', config['keystorepassfile'],
1901                      '-keypass:file', config['keypassfile'],
1902                      '-dname', localconfig['keydname']])
1903     # TODO keypass should be sent via stdin
1904     if p.returncode != 0:
1905         raise BuildException("Failed to generate key", p.output)
1906     os.chmod(localconfig['keystore'], 0o0600)
1907     # now show the lovely key that was just generated
1908     p = FDroidPopen(['keytool', '-list', '-v',
1909                      '-keystore', localconfig['keystore'],
1910                      '-alias', localconfig['repo_keyalias'],
1911                      '-storepass:file', config['keystorepassfile']])
1912     logging.info(p.output.strip() + '\n\n')
1913
1914
1915 def write_to_config(thisconfig, key, value=None):
1916     '''write a key/value to the local config.py'''
1917     if value is None:
1918         origkey = key + '_orig'
1919         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1920     with open('config.py', 'r') as f:
1921         data = f.read()
1922     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1923     repl = '\n' + key + ' = "' + value + '"'
1924     data = re.sub(pattern, repl, data)
1925     # if this key is not in the file, append it
1926     if not re.match('\s*' + key + '\s*=\s*"', data):
1927         data += repl
1928     # make sure the file ends with a carraige return
1929     if not re.match('\n$', data):
1930         data += '\n'
1931     with open('config.py', 'w') as f:
1932         f.writelines(data)
1933
1934
1935 def parse_xml(path):
1936     return XMLElementTree.parse(path).getroot()
1937
1938
1939 def string_is_integer(string):
1940     try:
1941         int(string)
1942         return True
1943     except ValueError:
1944         return False
1945
1946
1947 def get_per_app_repos():
1948     '''per-app repos are dirs named with the packageName of a single app'''
1949
1950     # Android packageNames are Java packages, they may contain uppercase or
1951     # lowercase letters ('A' through 'Z'), numbers, and underscores
1952     # ('_'). However, individual package name parts may only start with
1953     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1954     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1955
1956     repos = []
1957     for root, dirs, files in os.walk(os.getcwd()):
1958         for d in dirs:
1959             print('checking', root, 'for', d)
1960             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1961                 # standard parts of an fdroid repo, so never packageNames
1962                 continue
1963             elif p.match(d) \
1964                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
1965                 repos.append(d)
1966         break
1967     return repos